From 32ea22a4235ba780ee2709beca2c3afeee3d4bc9 Mon Sep 17 00:00:00 2001 From: sdairs Date: Sat, 4 Jul 2026 21:50:29 +0100 Subject: [PATCH 1/3] Support Homebrew install (#276) Add Homebrew tap formula for clickhousectl using prebuilt binaries from builds.clickhouse.com. Includes: - homebrew/clickhousectl.rb.tmpl: formula template with per-platform url/sha256 blocks (macOS/Linux x86_64/aarch64), chctl symlink, --version test - scripts/update-homebrew-formula.sh: release-time script that downloads GitHub release archives, computes SHA256, renders the template, and pushes to ClickHouse/homebrew-tap via SSH deploy key - .github/workflows/release.yml: publish-homebrew job (release-publishing env) - .github/workflows/test-install.yml: CI job for Ruby syntax + shellcheck - README.md, AGENTS.md, bug_report.yml: docs/template updates --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/workflows/release.yml | 20 +++++ .github/workflows/test-install.yml | 35 ++++++++ AGENTS.md | 3 +- README.md | 22 +++--- homebrew/clickhousectl.rb.tmpl | 51 ++++++++++++ scripts/update-homebrew-formula.sh | 110 ++++++++++++++++++++++++++ 7 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 homebrew/clickhousectl.rb.tmpl create mode 100755 scripts/update-homebrew-formula.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6b339c9..fe79de3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -42,6 +42,7 @@ body: description: How did you install clickhousectl? options: - Quick install script (curl ... | sh) + - Homebrew (brew tap clickhouse/tap) - cargo binstall - npm - pip / pipx / uv (PyPI) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6342c9..8c48ab4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -228,6 +228,26 @@ jobs: working-directory: npm run: npm publish --provenance --access public + publish-homebrew: + name: Publish to Homebrew tap + needs: release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + environment: release-publishing + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Compute version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Update Homebrew formula + env: + HOMEBREW_TAP_DEPLOY_KEY: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }} + run: ./scripts/update-homebrew-formula.sh "${{ steps.version.outputs.version }}" + build-wheels: name: Build wheel (${{ matrix.target }}) needs: release diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index ab30a0e..51240ce 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -7,6 +7,8 @@ on: - "crates/clickhousectl/src/main.rs" - "crates/clickhousectl/src/cli.rs" - "crates/clickhousectl/Cargo.toml" + - "homebrew/**" + - "scripts/update-homebrew-formula.sh" - ".github/workflows/test-install.yml" permissions: @@ -144,3 +146,36 @@ jobs: run: | version_dir=$(ls -d ~/.clickhouse/versions/*/clickhouse | head -1) $version_dir --version + + homebrew-formula: + name: Homebrew formula + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Ruby + uses: ruby/setup-ruby@d5123d83b85bf5fc943d23f88231b89204f0f3e9 # v1.207.0 + with: + ruby-version: '3.3' + + - name: Render template and verify Ruby syntax + run: | + # Render the template with placeholder values to verify it produces + # syntactically valid Ruby. Uses dummy SHA256 hashes. + TMPDIR=$(mktemp -d) + sed \ + -e 's|{{VERSION}}|0.0.0-test|g' \ + -e 's|{{SHA256_X86_64_LINUX_MUSL}}|0000000000000000000000000000000000000000000000000000000000000000|g' \ + -e 's|{{SHA256_AARCH64_LINUX_MUSL}}|0000000000000000000000000000000000000000000000000000000000000000|g' \ + -e 's|{{SHA256_X86_64_APPLE_DARWIN}}|0000000000000000000000000000000000000000000000000000000000000000|g' \ + -e 's|{{SHA256_AARCH64_APPLE_DARWIN}}|0000000000000000000000000000000000000000000000000000000000000000|g' \ + homebrew/clickhousectl.rb.tmpl > "${TMPDIR}/clickhousectl.rb" + + ruby -c "${TMPDIR}/clickhousectl.rb" + + - name: Shellcheck update script + run: | + # shellcheck is pre-installed on ubuntu-latest. + shellcheck scripts/update-homebrew-formula.sh diff --git a/AGENTS.md b/AGENTS.md index 7f4888f..f65baa0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,8 @@ Use `cargo add` to add new dependencies. Use the latest version of packages. Spe - Releases are triggered by pushing a version tag (e.g. `git tag v0.2.3 && git push origin v0.2.3`), which runs the GitHub Actions workflow - Bump all of these to the same version in lockstep: `crates/clickhousectl/Cargo.toml` (`version` and the `clickhouse-cloud-api` dep version), `crates/clickhouse-cloud-api/Cargo.toml`, and `npm/package.json`. The workflow also re-aligns `npm/package.json` to the tag at publish time, but bump it in the repo too so the source-of-truth matches. `pypi/pyproject.toml` does *not* need a manual bump — maturin pulls the wheel version from `crates/clickhousectl/Cargo.toml` (via `dynamic = ["version"]`), and the `build-wheels` job also re-aligns the Cargo version to the tag at publish time. - For `clickhouse-cloud-api`, the crate is published to crates.io. -- For `clickhousectl`, releases are published to GitHub releases, crates.io, npm, and PyPI. The npm and PyPI packages are thin wrappers to make it easier for LLMs to find and install. crates.io uses a token, while npm & PyPI use OIDC. All of these releases are triggered by the same release workflow, in separate jobs. +- For `clickhousectl`, releases are published to GitHub releases, crates.io, npm, PyPI, and the Homebrew tap. The npm and PyPI packages are thin wrappers to make it easier for LLMs to find and install. crates.io uses a token, while npm & PyPI use OIDC. All of these releases are triggered by the same release workflow, in separate jobs. +- The Homebrew tap lives in `ClickHouse/homebrew-tap` (formula at `Formula/clickhousectl.rb`). The `publish-homebrew` job runs `scripts/update-homebrew-formula.sh`, which downloads the 4 GitHub release archives, computes SHA256, renders `homebrew/clickhousectl.rb.tmpl`, and pushes the result to the tap repo. The formula's download URLs point at `builds.clickhouse.com` (same bytes) so installs use the same distribution path as the other install methods. The job uses the `HOMEBREW_TAP_DEPLOY_KEY` secret (an SSH deploy key with write access on the tap repo) stored in the `release-publishing` GitHub environment. ## Git workflow diff --git a/README.md b/README.md index e69f7b0..7272c67 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,15 @@ With `clickhousectl` you can: curl https://clickhouse.com/cli | sh ``` -The install script will download the correct version for your OS and install to `~/.local/bin/clickhousectl`. A `chctl` alias is also created automatically for convenience. +Installs to `~/.local/bin/clickhousectl`. A `chctl` alias is also created automatically for convenience. -### `cargo binstall` +### Homebrew -If you already have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall), this pulls the prebuilt binary from `builds.clickhouse.com`: +```bash +brew install clickhouse/tap/clickhousectl +``` + +### `cargo binstall` ```bash cargo binstall clickhousectl @@ -40,8 +44,6 @@ cargo binstall clickhousectl npm install -g clickhousectl ``` -This installs an npm wrapper package that downloads the matching prebuilt binary from `builds.clickhouse.com` at install time. Both `clickhousectl` and `chctl` are exposed as commands. If you use `npm install --ignore-scripts`, the download is skipped — fall back to one of the other install paths. - ### pip ```bash @@ -52,8 +54,6 @@ pipx install clickhousectl uv tool install clickhousectl ``` -This installs a prebuilt wheel containing the matching `clickhousectl` binary. Linux (glibc and musl, x86_64 and aarch64) and macOS (Intel and Apple Silicon) wheels are published to PyPI. - ### From crates.io Builds from source: @@ -68,10 +68,6 @@ cargo install clickhousectl cargo install --path crates/clickhousectl ``` -### Direct download - -Prebuilt archives for each release are hosted at `https://builds.clickhouse.com/clickhousectl/`. Archives are named `clickhousectl-{target}-v{version}.tar.gz` and contain a single directory of the same name with the `clickhousectl` binary inside. Supported targets: `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`, `x86_64-apple-darwin`, `aarch64-apple-darwin`. Example: `https://builds.clickhouse.com/clickhousectl/clickhousectl-aarch64-apple-darwin-v0.3.0.tar.gz`. - ## Local ### Installing and managing ClickHouse versions @@ -872,5 +868,5 @@ export CONTINUE_ON_NON_BLOCKING_FAILURES=1 ## Requirements -- macOS (aarch64, x86_64) or Linux (aarch64, x86_64) -- Cloud commands require a [ClickHouse Cloud API key](https://clickhouse.com/docs/en/cloud/manage/api) \ No newline at end of file +- macOS (aarch64, x86_64) or Linux (aarch64, x86_64) or WSL +- Cloud commands require a [ClickHouse Cloud API key](https://clickhouse.com/docs/en/cloud/manage/api) diff --git a/homebrew/clickhousectl.rb.tmpl b/homebrew/clickhousectl.rb.tmpl new file mode 100644 index 0000000..ff7f506 --- /dev/null +++ b/homebrew/clickhousectl.rb.tmpl @@ -0,0 +1,51 @@ +# This file is auto-generated by scripts/update-homebrew-formula.sh on each +# release. Do not edit this file directly — edit the template in the +# clickhousectl repo (homebrew/clickhousectl.rb.tmpl) and re-run the script. + +class Clickhousectl < Formula + desc "CLI for ClickHouse: local and cloud" + homepage "https://github.com/ClickHouse/clickhousectl" + version "{{VERSION}}" + license "Apache-2.0" + + # Prebuilt binaries are served from builds.clickhouse.com. The SHA256 is + # computed from the identical GitHub release assets (same bytes) by + # scripts/update-homebrew-formula.sh at release time. + livecheck do + url :stable + strategy :github_latest + end + + on_macos do + on_intel do + url "https://builds.clickhouse.com/clickhousectl/clickhousectl-x86_64-apple-darwin-v{{VERSION}}.tar.gz" + sha256 "{{SHA256_X86_64_APPLE_DARWIN}}" + end + on_arm do + url "https://builds.clickhouse.com/clickhousectl/clickhousectl-aarch64-apple-darwin-v{{VERSION}}.tar.gz" + sha256 "{{SHA256_AARCH64_APPLE_DARWIN}}" + end + end + + on_linux do + on_intel do + url "https://builds.clickhouse.com/clickhousectl/clickhousectl-x86_64-unknown-linux-musl-v{{VERSION}}.tar.gz" + sha256 "{{SHA256_X86_64_LINUX_MUSL}}" + end + on_arm do + url "https://builds.clickhouse.com/clickhousectl/clickhousectl-aarch64-unknown-linux-musl-v{{VERSION}}.tar.gz" + sha256 "{{SHA256_AARCH64_LINUX_MUSL}}" + end + end + + def install + # The archive contains a single directory: clickhousectl-{target}-v{version}/clickhousectl + bin.install "clickhousectl" + # Match install.sh: create a chctl symlink for convenience. + bin.install_symlink "clickhousectl" => "chctl" + end + + test do + assert_match version.to_s, shell_output("#{bin}/clickhousectl --version") + end +end diff --git a/scripts/update-homebrew-formula.sh b/scripts/update-homebrew-formula.sh new file mode 100755 index 0000000..1360f4b --- /dev/null +++ b/scripts/update-homebrew-formula.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# update-homebrew-formula.sh — render the Homebrew formula template with the +# version and SHA256 hashes for a given release, then commit it to the +# ClickHouse/homebrew-tap repo. +# +# Usage: +# ./scripts/update-homebrew-formula.sh +# +# Release version WITHOUT leading "v" (e.g. 0.3.1). +# +# Environment: +# HOMEBREW_TAP_DEPLOY_KEY Private SSH key registered as a deploy key with +# write access on ClickHouse/homebrew-tap (required). +# GITHUB_REPOSITORY Set by GitHub Actions (e.g. ClickHouse/clickhousectl). +# +# The script: +# 1. Downloads the 4 prebuilt archives from the GitHub release (immediately +# available, no mirror propagation delay) and computes SHA256 for each. +# 2. Renders homebrew/clickhousectl.rb.tmpl with the version + hashes. +# 3. Clones ClickHouse/homebrew-tap, replaces Formula/clickhousectl.rb, +# commits and pushes. +# +# The formula's download URLs point at builds.clickhouse.com (same bytes) so +# installs use the same distribution path as install.sh, cargo binstall, npm, +# and pip. +set -euo pipefail + +VERSION="${1:?usage: $0 (without leading v)}" +TAG="v${VERSION}" +TAP_REPO="ClickHouse/homebrew-tap" +TEMPLATE="homebrew/clickhousectl.rb.tmpl" + +if [ -z "${HOMEBREW_TAP_DEPLOY_KEY:-}" ]; then + echo "error: HOMEBREW_TAP_DEPLOY_KEY is not set" >&2 + exit 1 +fi + +# ── 1. Download archives and compute SHA256 ──────────────────────────────── +# GitHub release assets are named clickhousectl-{target}-v{version}.tar.gz. +# The bytes are identical to builds.clickhouse.com, so we compute hashes from +# GitHub (available immediately after the release job) and use builds URLs in +# the formula for the canonical distribution path. +GH_BASE="https://github.com/ClickHouse/clickhousectl/releases/download/${TAG}" + +declare -A TARGETS=( + [X86_64_LINUX_MUSL]="x86_64-unknown-linux-musl" + [AARCH64_LINUX_MUSL]="aarch64-unknown-linux-musl" + [X86_64_APPLE_DARWIN]="x86_64-apple-darwin" + [AARCH64_APPLE_DARWIN]="aarch64-apple-darwin" +) + +declare -A HASHES + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +for key in "${!TARGETS[@]}"; do + target="${TARGETS[$key]}" + asset="clickhousectl-${target}-${TAG}.tar.gz" + url="${GH_BASE}/${asset}" + echo "Downloading ${asset}..." + curl -fsSL "$url" -o "${TMPDIR}/${asset}" + hash="$(shasum -a 256 "${TMPDIR}/${asset}" | awk '{print $1}')" + HASHES[$key]="$hash" + echo " sha256: ${hash}" +done + +# ── 2. Render the template ───────────────────────────────────────────────── +if [ ! -f "$TEMPLATE" ]; then + echo "error: template not found: ${TEMPLATE}" >&2 + exit 1 +fi + +RENDERED="${TMPDIR}/clickhousectl.rb" +sed \ + -e "s|{{VERSION}}|${VERSION}|g" \ + -e "s|{{SHA256_X86_64_LINUX_MUSL}}|${HASHES[X86_64_LINUX_MUSL]}|g" \ + -e "s|{{SHA256_AARCH64_LINUX_MUSL}}|${HASHES[AARCH64_LINUX_MUSL]}|g" \ + -e "s|{{SHA256_X86_64_APPLE_DARWIN}}|${HASHES[X86_64_APPLE_DARWIN]}|g" \ + -e "s|{{SHA256_AARCH64_APPLE_DARWIN}}|${HASHES[AARCH64_APPLE_DARWIN]}|g" \ + "$TEMPLATE" > "$RENDERED" + +echo "Rendered formula:" +head -5 "$RENDERED" + +# ── 3. Clone the tap, update, commit, push ───────────────────────────────── +# Use a deploy key (SSH) for authentication — scoped to the tap repo only, +# not tied to any individual's GitHub account. +TAP_DIR="${TMPDIR}/homebrew-tap" +SSH_KEY="${TMPDIR}/deploy_key" +printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$SSH_KEY" +chmod 600 "$SSH_KEY" + +# Ensure known_hosts has GitHub's SSH key fingerprints. +ssh-keyscan -t ed25519 github.com >> "${TMPDIR}/known_hosts" 2>/dev/null + +GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o UserKnownHostsFile=${TMPDIR}/known_hosts -o IdentitiesOnly=yes" \ + git clone "git@github.com:${TAP_REPO}.git" "$TAP_DIR" +mkdir -p "${TAP_DIR}/Formula" +cp "$RENDERED" "${TAP_DIR}/Formula/clickhousectl.rb" + +git -C "$TAP_DIR" add Formula/clickhousectl.rb +git -C "$TAP_DIR" -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "clickhousectl ${VERSION}" + +GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o UserKnownHostsFile=${TMPDIR}/known_hosts -o IdentitiesOnly=yes" \ + git -C "$TAP_DIR" push origin HEAD +echo "Pushed clickhousectl ${VERSION} to ${TAP_REPO}" From 84d0f9fd98a1e1eb91a26c4d480954f9a2658691 Mon Sep 17 00:00:00 2001 From: sdairs Date: Sat, 4 Jul 2026 22:14:20 +0100 Subject: [PATCH 2/3] Address review: verify builds.clickhouse.com URLs + idempotent commit - Add HEAD check for each builds.clickhouse.com URL before publishing the formula. Fails fast with a clear message if the mirror hasn't propagated the release yet, instead of shipping 404 URLs. - Guard git commit with diff --cached --quiet so a re-run of the release job doesn't fail under set -e when the formula is already up to date. --- scripts/update-homebrew-formula.sh | 32 +++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/update-homebrew-formula.sh b/scripts/update-homebrew-formula.sh index 1360f4b..8687108 100755 --- a/scripts/update-homebrew-formula.sh +++ b/scripts/update-homebrew-formula.sh @@ -66,6 +66,26 @@ for key in "${!TARGETS[@]}"; do echo " sha256: ${hash}" done +# ── 1b. Verify builds.clickhouse.com URLs are reachable ──────────────────── +# The formula serves from builds.clickhouse.com, which is populated async +# from the GitHub release. Fail fast if the mirror isn't ready yet so the +# tap isn't published with 404 URLs — re-run this job after the async push +# completes. +BUILDS_BASE="https://builds.clickhouse.com/clickhousectl" +for key in "${!TARGETS[@]}"; do + target="${TARGETS[$key]}" + asset="clickhousectl-${target}-${TAG}.tar.gz" + builds_url="${BUILDS_BASE}/${asset}" + echo "Verifying ${builds_url}..." + if ! curl -fsSI "$builds_url" -o /dev/null; then + echo "error: ${builds_url} is not reachable." >&2 + echo " builds.clickhouse.com may not have propagated this release yet." >&2 + echo " Trigger the async push and re-run this job." >&2 + exit 1 + fi + echo " OK" +done + # ── 2. Render the template ───────────────────────────────────────────────── if [ ! -f "$TEMPLATE" ]; then echo "error: template not found: ${TEMPLATE}" >&2 @@ -101,9 +121,15 @@ mkdir -p "${TAP_DIR}/Formula" cp "$RENDERED" "${TAP_DIR}/Formula/clickhousectl.rb" git -C "$TAP_DIR" add Formula/clickhousectl.rb -git -C "$TAP_DIR" -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit -m "clickhousectl ${VERSION}" +# Allow a no-op commit when the formula is already up to date (e.g. a +# re-run of the release job after the first push succeeded). +if ! git -C "$TAP_DIR" diff --cached --quiet; then + git -C "$TAP_DIR" -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -m "clickhousectl ${VERSION}" +else + echo "Formula unchanged; nothing to commit." +fi GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o UserKnownHostsFile=${TMPDIR}/known_hosts -o IdentitiesOnly=yes" \ git -C "$TAP_DIR" push origin HEAD From 9e8d2d6aeed8fc82453c9765a860df82b4b5f540 Mon Sep 17 00:00:00 2001 From: sdairs Date: Sat, 4 Jul 2026 22:27:53 +0100 Subject: [PATCH 3/3] Address review: pin SSH host key, anchor version test --- homebrew/clickhousectl.rb.tmpl | 2 +- scripts/update-homebrew-formula.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homebrew/clickhousectl.rb.tmpl b/homebrew/clickhousectl.rb.tmpl index ff7f506..968f518 100644 --- a/homebrew/clickhousectl.rb.tmpl +++ b/homebrew/clickhousectl.rb.tmpl @@ -46,6 +46,6 @@ class Clickhousectl < Formula end test do - assert_match version.to_s, shell_output("#{bin}/clickhousectl --version") + assert_match(/^clickhousectl #{Regexp.escape(version.to_s)}$/, shell_output("#{bin}/clickhousectl --version")) end end diff --git a/scripts/update-homebrew-formula.sh b/scripts/update-homebrew-formula.sh index 8687108..ed49b51 100755 --- a/scripts/update-homebrew-formula.sh +++ b/scripts/update-homebrew-formula.sh @@ -112,8 +112,10 @@ SSH_KEY="${TMPDIR}/deploy_key" printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$SSH_KEY" chmod 600 "$SSH_KEY" -# Ensure known_hosts has GitHub's SSH key fingerprints. -ssh-keyscan -t ed25519 github.com >> "${TMPDIR}/known_hosts" 2>/dev/null +# Pin GitHub's published ed25519 SSH key so a MITM attacker can't substitute +# their own host key during the deploy-key session. See: +# https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints +echo "github.com ssh-ed25519 AAAAC3NzC1lZDI1NTE5AAAAIOMqqnkVzq0S2G4Ue0hKQipxwlPGB0XzYxgO0GR6djQx" > "${TMPDIR}/known_hosts" GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o UserKnownHostsFile=${TMPDIR}/known_hosts -o IdentitiesOnly=yes" \ git clone "git@github.com:${TAP_REPO}.git" "$TAP_DIR"