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/ci.yml b/.github/workflows/ci.yml index 6efcb6a..1c21697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,23 @@ jobs: - name: Vulnerability scan run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + # 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 + + # 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/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/.github/workflows/release.yml b/.github/workflows/release.yml index dd52539..f852cdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +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 +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 @@ -32,11 +96,48 @@ 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 }} + + # 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: distribution: goreleaser - version: latest + version: "~> v2" 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/.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..3bf5537 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json version: 2 project_name: kaiten @@ -5,6 +6,9 @@ project_name: kaiten before: hooks: - go mod tidy + # Completions ship in every archive and Linux package, so they are + # generated once here from the binary that is about to be released. + - sh -c 'mkdir -p completions && for shell in bash zsh fish; do go run ./cmd/kaiten completion "$shell" > "completions/kaiten.$shell"; done' builds: - id: kaiten @@ -25,10 +29,12 @@ builds: - -X main.commit={{.Commit}} - -X main.date={{.Date}} +# install/install.sh and install/install.ps1 download these by name, so the +# template is part of the install contract: kaiten__.tar.gz|zip. archives: - - formats: [tar.gz] - name_template: >- - {{ .ProjectName }}_{{ .Os }}_{{ .Arch }} + - id: archives + formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows formats: [zip] @@ -39,28 +45,121 @@ archives: - LICENSE - NOTICE - README.md + - completions/* checksum: name_template: "checksums.txt" +# .deb, .rpm and .apk, attached to the release next to the archives. Named +# like the archives, without the version, so that +# releases/latest/download/kaiten_linux_amd64.deb is a stable URL; the version +# lives in the package metadata, where dpkg, rpm and apk read it. +nfpms: + - id: packages + package_name: kaiten + file_name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + vendor: Kaiten + homepage: https://kaiten.sh + maintainer: Kaiten + description: Command-line interface for the Kaiten API. + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + section: utils + contents: + - src: completions/kaiten.bash + dst: /usr/share/bash-completion/completions/kaiten + - src: completions/kaiten.zsh + dst: /usr/share/zsh/site-functions/_kaiten + - src: completions/kaiten.fish + dst: /usr/share/fish/vendor_completions.d/kaiten.fish + # The doc/license content types are rpm-only; deb and apk silently skip + # them, so each packager gets the license file in its own convention. + - src: LICENSE + dst: /usr/share/licenses/kaiten/LICENSE + type: license + packager: rpm + - src: NOTICE + dst: /usr/share/licenses/kaiten/NOTICE + type: license + packager: rpm + - src: LICENSE + dst: /usr/share/doc/kaiten/copyright + packager: deb + - src: NOTICE + dst: /usr/share/doc/kaiten/NOTICE + packager: deb + - src: LICENSE + dst: /usr/share/licenses/kaiten/LICENSE + packager: apk + - src: NOTICE + dst: /usr/share/licenses/kaiten/NOTICE + packager: apk + +# ghcr.io/kaitencloud/cli: and :latest, one multi-platform image. The +# workflow logs in to GHCR with the job's GITHUB_TOKEN. Built in the publish +# phase, so `--snapshot` produces per-platform local images and `--skip=docker` +# none at all. +dockers_v2: + - id: kaiten + ids: + - kaiten + images: + - ghcr.io/kaitencloud/cli + tags: + - "{{ .Version }}" + # A pre-release tag (v1.2.0-rc.1) does not move latest. + - "{{ if not .Prerelease }}latest{{ end }}" + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile + extra_files: + - LICENSE + - NOTICE + labels: + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: Command-line interface for the Kaiten API + org.opencontainers.image.url: https://github.com/kaitencloud/cli + org.opencontainers.image.source: https://github.com/kaitencloud/cli + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.created: "{{ .Date }}" + org.opencontainers.image.licenses: Apache-2.0 + +# 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 + # 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: | + ## Install + + ```shell + # macOS and Linux + curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- {{ .Version }} + + # Windows + & ([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 + ``` + + 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/.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/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: 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 54d5e7a..3bba7d8 100644 --- a/README.md +++ b/README.md @@ -22,27 +22,97 @@ output and exit codes a script can branch on. ## Installation -**Pre-built binaries** for Linux, macOS and Windows (amd64 and arm64) are attached -to every release on the [releases page](https://github.com/kaitencloud/cli/releases), -with a `checksums.txt`. +Every method below installs the same binary from the +[GitHub release](https://github.com/kaitencloud/cli/releases). Check what you got with +`kaiten version`. -**With Go 1.25 or newer:** +### macOS + +```shell +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +``` + +The script verifies the archive against the release's `checksums.txt` and installs into +`/usr/local/bin` (`KAITEN_INSTALL_DIR` changes that). + +### Linux + +```shell +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +``` + +Or a package for your distribution (replace `amd64` with `arm64` as needed). Each installs +the binary and shell completions: + +```shell +# Debian, Ubuntu +curl -fsSLO https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.deb +sudo dpkg -i kaiten_linux_amd64.deb + +# Fedora, RHEL, openSUSE +sudo rpm -i https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.rpm + +# Alpine +curl -fsSLO https://github.com/kaitencloud/cli/releases/latest/download/kaiten_linux_amd64.apk +sudo apk add --allow-untrusted kaiten_linux_amd64.apk +``` + +### Windows + +```powershell +irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex +``` + +The script verifies the archive against `checksums.txt`, installs `kaiten.exe` into +`%LOCALAPPDATA%\Programs\kaiten` (no administrator rights needed) and adds it to your +user `PATH`. + +### Container + +```shell +docker run --rm -e KAITEN_BASE_URL -e KAITEN_AUTH_TOKEN ghcr.io/kaitencloud/cli:latest instances list +``` + +`ghcr.io/kaitencloud/cli` is tagged `latest` and with each version, built for +`linux/amd64` and `linux/arm64`, and runs as a non-root user. Pass the configuration +through the environment: the image keeps no config file between runs. + +### Go + +With Go 1.25 or newer: ```shell go install github.com/kaitencloud/cli/cmd/kaiten@latest ``` -**From source**, with [Task](https://taskfile.dev) installed: +### Binaries + +Every release attaches `kaiten__.tar.gz` (`.zip` on Windows) for Linux, macOS and +Windows on amd64 and arm64, with a `checksums.txt` of SHA-256 sums. Unpack it and put +`kaiten` on your `PATH`. + +### A specific version ```shell -git clone https://github.com/kaitencloud/cli.git && cd cli -task build # bin/kaiten +curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- 1.2.3 ``` -**Shell completion** is available for bash, zsh, fish and PowerShell: +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1))) -Version 1.2.3 +``` + +`go install ...@v1.2.3` and the container tags pin a version directly. + +### Shell completion + +The Linux packages and the archives ship completions for bash, zsh and fish. Any other +install can generate them from the binary: ```shell kaiten completion zsh > "${fpath[1]}/_kaiten" +kaiten completion bash > /etc/bash_completion.d/kaiten +kaiten completion fish > ~/.config/fish/completions/kaiten.fish +kaiten completion powershell | Out-String | Invoke-Expression ``` ## Quick start @@ -267,8 +337,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); @@ -276,8 +347,50 @@ 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 + +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. + +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 80bb6aa..7163d38 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -52,11 +52,26 @@ 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. + # 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 + cmds: + # Everything a tag would release, from the working tree, without a tag or + # a registry: six binaries, the archives and .deb/.rpm/.apk. Docker is + # skipped because the arm64 image needs QEMU; CI has it, a laptop may not. + - go run github.com/goreleaser/goreleaser/v2@{{.GORELEASER_VERSION}} release --snapshot --clean --skip=publish,docker coverage: desc: Write a coverage profile to coverage.out diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 0000000..6df084e --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Installs the Kaiten CLI from its GitHub releases on Windows. + +.DESCRIPTION + Latest release, into $Env:LOCALAPPDATA\Programs\kaiten (no administrator rights needed): + + irm https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.ps1 | iex + + A specific version or directory, by saving the script and running it with parameters: + + .\install.ps1 -Version 1.2.3 -InstallDir C:\tools\kaiten + + The archive's SHA-256 is verified against the release's checksums.txt before anything is installed. + +.PARAMETER Version + Version to install, with or without the leading v. Defaults to $Env:KAITEN_VERSION, then the latest release. + +.PARAMETER InstallDir + Directory to install kaiten.exe into. Defaults to $Env:KAITEN_INSTALL_DIR, then $Env:LOCALAPPDATA\Programs\kaiten. + The directory is added to the user PATH when it is not there yet. + +.PARAMETER DownloadUrl + Base URL serving the release assets, for mirrors. Defaults to $Env:KAITEN_DOWNLOAD_URL, then the GitHub release. +#> +param ( + [string]$Version = $Env:KAITEN_VERSION, + [string]$InstallDir = $(if ($Env:KAITEN_INSTALL_DIR) { $Env:KAITEN_INSTALL_DIR } else { Join-Path $Env:LOCALAPPDATA 'Programs\kaiten' }), + [string]$DownloadUrl = $Env:KAITEN_DOWNLOAD_URL +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$Repo = 'kaitencloud/cli' +$Binary = 'kaiten.exe' + +$arch = switch ($Env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { throw "Unsupported architecture: $Env:PROCESSOR_ARCHITECTURE (releases ship amd64 and arm64)" } +} + +$asset = "kaiten_windows_$arch.zip" +if ($DownloadUrl) { + $base = $DownloadUrl.TrimEnd('/') +} elseif ($Version) { + if (-not $Version.StartsWith('v')) { $Version = "v$Version" } + $base = "https://github.com/$Repo/releases/download/$Version" +} else { + $base = "https://github.com/$Repo/releases/latest/download" +} + +$tmp = Join-Path ([IO.Path]::GetTempPath()) ('kaiten-install-' + [IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + Write-Host "Downloading $base/$asset" + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile (Join-Path $tmp $asset) + Invoke-WebRequest -UseBasicParsing -Uri "$base/checksums.txt" -OutFile (Join-Path $tmp 'checksums.txt') + + $expected = Get-Content (Join-Path $tmp 'checksums.txt') | + Where-Object { ($_ -split '\s+')[1] -eq $asset } | + ForEach-Object { ($_ -split '\s+')[0] } | + Select-Object -First 1 + if (-not $expected) { throw "checksums.txt has no entry for $asset" } + + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $tmp $asset)).Hash + if ($actual.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { + throw "Checksum mismatch for ${asset}: expected $expected, got $actual" + } + + Expand-Archive -Force -Path (Join-Path $tmp $asset) -DestinationPath $tmp + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Copy-Item -Force (Join-Path $tmp $Binary) (Join-Path $InstallDir $Binary) +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +$exe = Join-Path $InstallDir $Binary +Write-Host "Installed $(& $exe version) to $exe" + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if (($userPath -split ';') -notcontains $InstallDir) { + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') + $Env:PATH = "$Env:PATH;$InstallDir" + Write-Host "Added $InstallDir to your user PATH. Open a new terminal for it to take effect." +} +Write-Host "Run 'kaiten config set base-url ' and 'kaiten doctor' to get started." diff --git a/install/install.sh b/install/install.sh new file mode 100644 index 0000000..f0e4e3e --- /dev/null +++ b/install/install.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Installs the Kaiten CLI from its GitHub releases on Linux and macOS. +# +# curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/kaitencloud/cli/main/install/install.sh | sh -s -- 1.2.3 +# +# Environment: +# KAITEN_VERSION version to install, with or without the leading v (default: latest release) +# KAITEN_INSTALL_DIR directory to install into (default: /usr/local/bin, with sudo when needed) +# KAITEN_DOWNLOAD_URL base URL serving the release assets, for mirrors (default: the GitHub release) +# +# The archive's SHA-256 is verified against the release's checksums.txt before anything is installed. +set -eu + +REPO="kaitencloud/cli" +BINARY="kaiten" +INSTALL_DIR="${KAITEN_INSTALL_DIR:-/usr/local/bin}" +VERSION="${1:-${KAITEN_VERSION:-}}" + +say() { printf '%s\n' "$*"; } +fail() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +fetch() { + if have curl; then + curl -fsSL --retry 3 -o "$2" "$1" + elif have wget; then + wget -q -O "$2" "$1" + else + fail "curl or wget is required" + fi +} + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$os" in + linux | darwin) ;; + mingw* | msys* | cygwin*) fail "on Windows, run: powershell -c \"irm https://raw.githubusercontent.com/$REPO/main/install/install.ps1 | iex\"" ;; + *) fail "unsupported operating system: $os" ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) fail "unsupported architecture: $arch (releases ship amd64 and arm64)" ;; +esac + +asset="${BINARY}_${os}_${arch}.tar.gz" +if [ -n "${KAITEN_DOWNLOAD_URL:-}" ]; then + base="${KAITEN_DOWNLOAD_URL%/}" +elif [ -n "$VERSION" ]; then + case "$VERSION" in v*) ;; *) VERSION="v$VERSION" ;; esac + base="https://github.com/$REPO/releases/download/$VERSION" +else + base="https://github.com/$REPO/releases/latest/download" +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT INT TERM + +say "Downloading $base/$asset" +fetch "$base/$asset" "$tmp/$asset" +fetch "$base/checksums.txt" "$tmp/checksums.txt" + +expected=$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/checksums.txt") +[ -n "$expected" ] || fail "checksums.txt has no entry for $asset" +if have sha256sum; then + actual=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') +elif have shasum; then + actual=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required to verify the download" +fi +[ "$actual" = "$expected" ] || fail "checksum mismatch for $asset: expected $expected, got $actual" + +tar -xzf "$tmp/$asset" -C "$tmp" "$BINARY" + +as_root() { + if [ -n "$use_sudo" ]; then sudo "$@"; else "$@"; fi +} + +# Writability is decided by the closest directory that already exists, so a +# missing ~/.local/bin is created by the user and a missing /opt/x by root. +probe="$INSTALL_DIR" +while [ ! -d "$probe" ]; do probe=$(dirname "$probe"); done +use_sudo="" +if [ ! -w "$probe" ]; then + have sudo || fail "cannot write to $INSTALL_DIR; set KAITEN_INSTALL_DIR to a writable directory" + say "Installing into $INSTALL_DIR needs sudo." + use_sudo=1 +fi +as_root mkdir -p "$INSTALL_DIR" +as_root install -m 755 "$tmp/$BINARY" "$INSTALL_DIR/$BINARY" + +say "Installed $("$INSTALL_DIR/$BINARY" version) to $INSTALL_DIR/$BINARY" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) say "Note: $INSTALL_DIR is not in your PATH." ;; +esac +say "Run 'kaiten config set base-url ' and 'kaiten doctor' to get started." 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": { + ".": {} + } +}