Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .github/workflows/formula-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Copyright 2026 Cloudsmith Ltd
name: Test formula on macOS

on:
pull_request:
push:
branches:
- main
# Homebrew itself changes underneath us; a scheduled run catches a formula
# that stops installing without this repository changing at all.
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:

permissions:
contents: read

env:
# A fixed known-good release, used purely as the "other end" of the upgrade
# and downgrade transitions. This is a deliberate baseline, not "the previous
# release": it does not need bumping when a new version ships.
BASELINE_VERSION: "1.20.1"
BASELINE_SHA256_ARM64: "2c2580eb8725467877f2a296d675fd681abe01050099a6405d5b4c37c6f3b901"
BASELINE_SHA256_X86_64: "a8e959909caab7d8d6fac390d530cd2a4bff3e70c97e12e06e2587a5b7ddfc85"
HOMEBREW_NO_ANALYTICS: 1
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_ENV_HINTS: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
HOMEBREW_NO_REQUIRE_TAP_TRUST: 1

jobs:
lifecycle:
name: ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-latest
arch: arm64
- runner: macos-15-intel
arch: x86_64

steps:
- uses: actions/checkout@v4

- name: Install this checkout as the tap
run: |
tap_dir="$(brew --repository)/Library/Taps/cloudsmith-io/homebrew-cloudsmith-cli"
mkdir -p "$tap_dir"
cp -R Formula Aliases "$tap_dir/"
echo "TAP_DIR=$tap_dir" >> "$GITHUB_ENV"

- name: Lint formula
run: |
brew style "$TAP_DIR/Formula/cloudsmith-cli.rb"
brew audit --formula cloudsmith-io/cloudsmith-cli/cloudsmith-cli

- name: Install
run: brew install cloudsmith-io/cloudsmith-cli/cloudsmith-cli

- name: Verify install
run: |
brew test cloudsmith-io/cloudsmith-cli/cloudsmith-cli
scripts/assert-cli-version.sh "$(sed -n 's/^ version "\(.*\)"$/\1/p' Formula/cloudsmith-cli.rb)"

- name: Uninstall
run: |
brew uninstall cloudsmith-cli
if command -v cloudsmith; then
echo "cloudsmith still on PATH after uninstall" >&2
exit 1
fi
if brew list --versions cloudsmith-cli; then
echo "keg still present after uninstall" >&2
exit 1
fi

- name: Install baseline version
run: |
python3 scripts/render-formula-version.py \
"$TAP_DIR/Formula/cloudsmith-cli.rb" \
"$BASELINE_VERSION" "$BASELINE_SHA256_ARM64" "$BASELINE_SHA256_X86_64"
brew install cloudsmith-io/cloudsmith-cli/cloudsmith-cli
scripts/assert-cli-version.sh "$BASELINE_VERSION"

- name: Upgrade to this formula
run: |
cp Formula/cloudsmith-cli.rb "$TAP_DIR/Formula/cloudsmith-cli.rb"
brew upgrade cloudsmith-io/cloudsmith-cli/cloudsmith-cli
scripts/assert-cli-version.sh "$(sed -n 's/^ version "\(.*\)"$/\1/p' Formula/cloudsmith-cli.rb)"

- name: Downgrade to baseline version
run: |
python3 scripts/render-formula-version.py \
"$TAP_DIR/Formula/cloudsmith-cli.rb" \
"$BASELINE_VERSION" "$BASELINE_SHA256_ARM64" "$BASELINE_SHA256_X86_64"
brew uninstall --force cloudsmith-cli
brew install cloudsmith-io/cloudsmith-cli/cloudsmith-cli
scripts/assert-cli-version.sh "$BASELINE_VERSION"

- name: Uninstall baseline version
run: brew uninstall cloudsmith-cli

pin-target:
name: pin ${{ matrix.version }} ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
# 1.19.0 ships macOS arm64 builds only, so it has no Intel entry.
- runner: macos-latest
arch: arm64
version: "1.19.0"
- runner: macos-latest
arch: arm64
version: "1.20.1"
- runner: macos-15-intel
arch: x86_64
version: "1.20.1"

steps:
- uses: actions/checkout@v4

- name: Install this checkout as the tap
run: |
tap_dir="$(brew --repository)/Library/Taps/cloudsmith-io/homebrew-cloudsmith-cli"
mkdir -p "$tap_dir"
cp -R Formula Aliases "$tap_dir/"
echo "TAP_DIR=$tap_dir" >> "$GITHUB_ENV"
echo "PIN=cloudsmith-cli@${{ matrix.version }}" >> "$GITHUB_ENV"

- name: Lint pin target
run: |
brew style "$TAP_DIR/Formula/$PIN.rb"
brew audit --formula "cloudsmith-io/cloudsmith-cli/$PIN"

- name: Install pin target
run: brew install "cloudsmith-io/cloudsmith-cli/$PIN"

- name: Verify pin target is usable and holdable
run: |
# Matched loosely so that both the current --version format and the
# older one 1.19.0 predates are accepted.
cloudsmith --version | tee /tmp/pin-version
grep -qF "${{ matrix.version }}" /tmp/pin-version
brew pin "$PIN"
brew list --pinned | grep -qF "$PIN"
brew unpin "$PIN"

- name: Uninstall pin target
run: brew uninstall "$PIN"
6 changes: 6 additions & 0 deletions Formula/cloudsmith-cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ class CloudsmithCli < Formula
regex(/^version=(\d+(?:\.\d+)+)$/i)
end

# The bundled libraries are private to the PyInstaller bundle and are resolved
# via @rpath, so Homebrew must not rewrite their dylib IDs: the absolute Cellar
# path does not fit in the Mach-O header padding of prebuilt wheels such as
# pydantic_core, which fails the install.
preserve_rpath

def install
# PyInstaller onedir bundle: the executable must stay next to _internal/.
libexec.install Dir["*"]
Expand Down
46 changes: 46 additions & 0 deletions Formula/cloudsmith-cli@1.19.0.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright 2026 Cloudsmith Ltd
#
# Pinnable rollback target for the last release before the CLI switched to a
# PyInstaller bundle. Kept so that anyone broken by a newer release can return
# to a known-good version with `brew install cloudsmith-cli@1.19.0`, rather than
# reconstructing an old formula out of this tap's git history.
#
# Intentionally frozen: this file describes 1.19.0 and should not be bumped.
class CloudsmithCliAT1190 < Formula
desc "Official Cloudsmith Command-Line Interface (pinned 1.19.0)"
homepage "https://docs.cloudsmith.com/developer-tools/cli"
url "https://github.com/cloudsmith-io/cloudsmith-cli/releases/download/v1.19.0/cloudsmith.pyz"
sha256 "c076e4b002ee07f26774c0f8a9134f52a73b16a3fb10adb31891475485e28038"
license "Apache-2.0"

keg_only :versioned_formula

# The PEX/zipapp bundles all Python dependencies, so we only need Python 3.10.
depends_on "python@3.10"

# The 1.19.0 zipapp bundles native wheels for macOS arm64 only: it carries no
# macosx x86_64 build of rpds-py, pydantic-core or cffi, so it cannot run on an
# Intel Mac. Fail with that up front rather than a PEX resolution dump.
on_macos do
depends_on arch: :arm64
end

def install
libexec.install "cloudsmith.pyz"

# Run the zipapp under the interpreter this formula depends on. Its
# `#!/usr/bin/env python3` shebang would otherwise pick up whatever python3
# comes first on PATH, which on some machines is older than the 3.10 the
# zipapp requires.
python = formula_opt_bin("python@3.10")/"python3.10"
(bin/"cloudsmith").write <<~BASH
#!/bin/bash
exec "#{python}" "#{libexec}/cloudsmith.pyz" "$@"
BASH
chmod 0755, bin/"cloudsmith"
end

test do
assert_match version.to_s, shell_output("#{bin}/cloudsmith --version")
end
end
40 changes: 40 additions & 0 deletions Formula/cloudsmith-cli@1.20.1.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2026 Cloudsmith Ltd
#
# Pinnable rollback target covering every supported platform. Kept alongside
# cloudsmith-cli@1.19.0, which is the escape hatch from the PyInstaller
# packaging but ships macOS arm64 builds only.
#
# Intentionally frozen: this file describes 1.20.1 and should not be bumped.
class CloudsmithCliAT1201 < Formula
desc "Official Cloudsmith Command-Line Interface (pinned 1.20.1)"
homepage "https://docs.cloudsmith.com/developer-tools/cli"
version "1.20.1"
license "Apache-2.0"

if OS.mac? && Hardware::CPU.arm?
url "https://dl.cloudsmith.io/public/cloudsmith/cli/raw/names/cloudsmith-cli-macos-arm64/versions/1.20.1/cloudsmith-1.20.1-macos-arm64.tar.gz"
sha256 "2c2580eb8725467877f2a296d675fd681abe01050099a6405d5b4c37c6f3b901"
elsif OS.mac? && Hardware::CPU.intel?
url "https://dl.cloudsmith.io/public/cloudsmith/cli/raw/names/cloudsmith-cli-macos-x86_64/versions/1.20.1/cloudsmith-1.20.1-macos-x86_64.tar.gz"
sha256 "a8e959909caab7d8d6fac390d530cd2a4bff3e70c97e12e06e2587a5b7ddfc85"
elsif OS.linux? && Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
url "https://dl.cloudsmith.io/public/cloudsmith/cli/raw/names/cloudsmith-cli-linux-aarch64-gnu/versions/1.20.1/cloudsmith-1.20.1-linux-aarch64-gnu.tar.gz"
sha256 "7ff869d1d059759a938d97bdc5173d7f481782dfa7677870797b8643bd09c95c"
elsif OS.linux? && Hardware::CPU.intel? && Hardware::CPU.is_64_bit?
url "https://dl.cloudsmith.io/public/cloudsmith/cli/raw/names/cloudsmith-cli-linux-x86_64-gnu/versions/1.20.1/cloudsmith-1.20.1-linux-x86_64-gnu.tar.gz"
sha256 "1738b6057cac7fb60dd9a6bd72fe335560ef51d93f79b052be2df379fb2fb385"
end

keg_only :versioned_formula

preserve_rpath

def install
libexec.install Dir["*"]
bin.write_exec_script libexec/"cloudsmith"
end

test do
assert_match "CLI Package Version: #{version}", shell_output("#{bin}/cloudsmith --version")
end
end
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,44 @@ To verify the installed CLI:
cloudsmith --version
```

## Holding or Rolling Back a Version

To stay on the version you already have and stop `brew upgrade` moving it:

```bash
brew pin cloudsmith-cli
```

Use `brew unpin cloudsmith-cli` to release it again.

This tap keeps two older versions as pinnable rollback targets:

| Formula | Version | Platforms |
| --- | --- | --- |
| `cloudsmith-cli@1.20.1` | 1.20.1, standalone binary | all supported platforms |
| `cloudsmith-cli@1.19.0` | 1.19.0, last Python zipapp release | macOS arm64 and Linux |

`cloudsmith-cli@1.19.0` is unavailable on Intel macOS because that release
shipped no Intel macOS builds of its native dependencies.

To roll back, uninstall the current version and install the target:

```bash
brew uninstall cloudsmith-cli
brew install cloudsmith-io/cloudsmith-cli/cloudsmith-cli@1.20.1
brew pin cloudsmith-cli@1.20.1
```

To roll back to any other version, extract that version's formula from this
tap's history into a tap of your own:

```bash
brew tap-new <your-org>/cloudsmith-cli-versions
brew extract --version=1.20.1 cloudsmith-io/cloudsmith-cli/cloudsmith-cli <your-org>/cloudsmith-cli-versions
brew uninstall cloudsmith-cli
brew install <your-org>/cloudsmith-cli-versions/cloudsmith-cli@1.20.1
```

## Supported Platforms

| Platform | Architecture |
Expand Down
27 changes: 27 additions & 0 deletions scripts/assert-cli-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Copyright 2026 Cloudsmith Ltd
#
# Assert that the cloudsmith CLI on PATH reports an expected version.
#
# Deliberately resolves the binary through PATH rather than through the keg, so
# that a formula which installs correctly but links the wrong version, or fails
# to link at all, is still caught.
set -euo pipefail

expected="${1:?usage: assert-cli-version.sh EXPECTED_VERSION}"

if ! command -v cloudsmith >/dev/null; then
printf 'cloudsmith is not on PATH\n' >&2
exit 1
fi

output="$(cloudsmith --version </dev/null)"
actual="$(printf '%s\n' "$output" | sed -n 's/^CLI Package Version: //p')"

if [ "$actual" != "$expected" ]; then
printf 'expected CLI version %s, got "%s"\nfull output:\n%s\n' \
"$expected" "$actual" "$output" >&2
exit 1
fi

printf 'cloudsmith on PATH reports %s\n' "$actual"
60 changes: 60 additions & 0 deletions scripts/render-formula-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
# Copyright 2026 Cloudsmith Ltd
#
# Repoint a rendered formula at a different Cloudsmith CLI version.
#
# Used by the macOS formula workflow to produce the second version needed to
# exercise upgrade and downgrade transitions.
#
# Only the macOS sha256 values are rewritten, because the transitions run on
# macOS runners. The Linux sha256 values are deliberately left untouched and
# must not be relied on in the rendered output.
import re
import sys

SHA256_LINE = re.compile(r'^(\s*sha256 ")[0-9a-f]{64}(")$')
VERSION_LINE = re.compile(r'^ version "(.+)"$', re.MULTILINE)
MACOS_SHA_KEYS = ("macos-arm64", "macos-x86_64")


def rewrite(formula, version, shas):
"""Return formula repointed at version, with macOS sha256 values replaced.

The version appears in the version stanza and in every url, so it is
replaced as a plain string. Each sha256 is matched to a platform by the url
line that precedes it, which is how the formula pairs them.
"""
current_version = VERSION_LINE.search(formula)
if not current_version:
raise SystemExit("no version stanza found in formula")
formula = formula.replace(current_version.group(1), version)

rendered = []
platform = None
for line in formula.split("\n"):
if ' url "' in line:
platform = next((key for key in shas if key in line), None)
sha256 = SHA256_LINE.match(line)
if sha256 and platform:
line = f"{sha256.group(1)}{shas[platform]}{sha256.group(2)}"
platform = None
rendered.append(line)
return "\n".join(rendered)


def main():
if len(sys.argv) != 5:
raise SystemExit(
f"usage: {sys.argv[0]} FORMULA VERSION ARM64_SHA256 X86_64_SHA256"
)
path, version, arm64_sha256, x86_64_sha256 = sys.argv[1:5]
shas = dict(zip(MACOS_SHA_KEYS, (arm64_sha256, x86_64_sha256)))

with open(path, encoding="utf-8") as formula:
rendered = rewrite(formula.read(), version, shas)
with open(path, "w", encoding="utf-8") as formula:
formula.write(rendered)


if __name__ == "__main__":
main()
Loading