diff --git a/.github/workflows/formula-test.yml b/.github/workflows/formula-test.yml new file mode 100644 index 0000000..9ff4f51 --- /dev/null +++ b/.github/workflows/formula-test.yml @@ -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" diff --git a/Formula/cloudsmith-cli.rb b/Formula/cloudsmith-cli.rb index 5fe0b73..d2ded63 100644 --- a/Formula/cloudsmith-cli.rb +++ b/Formula/cloudsmith-cli.rb @@ -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["*"] diff --git a/Formula/cloudsmith-cli@1.19.0.rb b/Formula/cloudsmith-cli@1.19.0.rb new file mode 100644 index 0000000..7d882bc --- /dev/null +++ b/Formula/cloudsmith-cli@1.19.0.rb @@ -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 diff --git a/Formula/cloudsmith-cli@1.20.1.rb b/Formula/cloudsmith-cli@1.20.1.rb new file mode 100644 index 0000000..f913d5a --- /dev/null +++ b/Formula/cloudsmith-cli@1.20.1.rb @@ -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 diff --git a/README.md b/README.md index e12c259..5c98a3d 100644 --- a/README.md +++ b/README.md @@ -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 /cloudsmith-cli-versions +brew extract --version=1.20.1 cloudsmith-io/cloudsmith-cli/cloudsmith-cli /cloudsmith-cli-versions +brew uninstall cloudsmith-cli +brew install /cloudsmith-cli-versions/cloudsmith-cli@1.20.1 +``` + ## Supported Platforms | Platform | Architecture | diff --git a/scripts/assert-cli-version.sh b/scripts/assert-cli-version.sh new file mode 100755 index 0000000..ee7feca --- /dev/null +++ b/scripts/assert-cli-version.sh @@ -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 &2 + exit 1 +fi + +printf 'cloudsmith on PATH reports %s\n' "$actual" diff --git a/scripts/render-formula-version.py b/scripts/render-formula-version.py new file mode 100755 index 0000000..9fe705a --- /dev/null +++ b/scripts/render-formula-version.py @@ -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()