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
40 changes: 40 additions & 0 deletions .github/workflows/bump-cli-version.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2026 Cloudsmith Ltd
name: Bump Cloudsmith CLI

# Opens a pull request that moves Formula/cloudsmith-cli.rb to a Cloudsmith
# CLI release, and merges it when the checks pass. The CLI release workflow
# sends the cloudsmith-cli-release event. Run the workflow by hand to move to
# a specific version.
on:
repository_dispatch:
types:
- cloudsmith-cli-release
workflow_dispatch:
inputs:
version:
description: The Cloudsmith CLI version to move to, for example 1.29.1.
required: true

permissions: {}

concurrency:
group: bump-cli-version
cancel-in-progress: false
queue: max

jobs:
bump:
name: Open the bump pull request
permissions:
contents: read
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Open the bump pull request
uses: cloudsmith-io/cloudsmith-cli/.github/actions/open-cli-bump-pr@34f5fd967daaab552a66dd9a890d550c47a39b0c # master
with:
version: ${{ github.event.client_payload.version || inputs.version }}
bump-command: python3 scripts/bump-cli-version.py "$VERSION"
Comment thread
cloudsmith-iduffy marked this conversation as resolved.
Resolved
merge: "true"
token: ${{ secrets.DOWNSTREAM_RELEASE_PUSH_TOKEN }}
ssh-signing-key: ${{ secrets.CLOUDSMITH_BOT_SSH_KEY }}
22 changes: 22 additions & 0 deletions .github/workflows/scripts-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2026 Cloudsmith Ltd
name: Test scripts

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Run the script tests
run: python3 -m unittest discover -s scripts -p 'test_*.py'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ temp/
*.tmp
*.tar.gz
*.pyz
__pycache__/

# OS-generated files
.DS_Store
Expand Down
42 changes: 22 additions & 20 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,34 @@

This repository is the Homebrew tap for the [Cloudsmith CLI](https://docs.cloudsmith.com/developer-tools/cli).

`Formula/cloudsmith-cli.rb` is **generated**, not hand-maintained. Everything else here is maintained by hand.
Everything here is maintained by hand. Each Cloudsmith CLI release changes only
the version, the sha256 values and the revision in `Formula/cloudsmith-cli.rb`.

## How the Main Formula Is Released

The Cloudsmith CLI release workflow owns `Formula/cloudsmith-cli.rb`. Its
`publish-homebrew` job renders
[`packaging/homebrew/cloudsmith-cli.rb.tmpl`](https://github.com/cloudsmith-io/cloudsmith-cli/blob/master/packaging/homebrew/cloudsmith-cli.rb.tmpl)
with the new version and each platform's SHA256, verifies those checksums against
the published downloads, and opens a bump pull request here as `cloudsmith-bot`.
Review it and merge with a squash merge. There is nothing to run in this
repository for a version bump.

Because that job copies the rendered file over `Formula/cloudsmith-cli.rb`
wholesale, **any change to the main formula must be made in the upstream
template**, or the next release will silently drop it.

If a formula fix is urgent, land it here to unblock users and open the matching
template change upstream in the same sitting. Add `revision 1` (or the next
number) when the CLI version itself has not changed, so that Homebrew treats it
as an upgrade and existing installs pick the fix up. The next release removes the
revision on its own.
Each Cloudsmith CLI release sends a `cloudsmith-cli-release` event to this
repository. The `Bump Cloudsmith CLI` workflow then runs
`scripts/bump-cli-version.py`. The script reads the sha256 of each platform
from the `SHA256SUMS` asset of the CLI release. It verifies each published
download against its sha256, then writes the new version and the sha256 values
into the formula. The workflow opens a bump pull request as `cloudsmith-bot` and
squash-merges it when the checks pass.

To move to a CLI version by hand, run the `Bump Cloudsmith CLI` workflow with
that version. To see the change locally, run
`python3 scripts/bump-cli-version.py 1.29.1`. The workflow needs the
`DOWNSTREAM_RELEASE_PUSH_TOKEN` and `CLOUDSMITH_BOT_SSH_KEY` secrets of
`cloudsmith-bot`.

To fix the formula, change `Formula/cloudsmith-cli.rb` here. The next release
keeps the change. Add `revision 1` (or the next number) when the CLI version
itself has not changed, so that Homebrew treats it as an upgrade and existing
installs pick the fix up. The next release removes the revision.

## What Is Maintained Here

- `Formula/cloudsmith-cli@<version>.rb` — pinned rollback targets. The release
job does not touch these, so they persist across releases. See the
- `Formula/cloudsmith-cli@<version>.rb` — pinned rollback targets. The bump
workflow does not touch these, so they persist across releases. See the
[README](README.md#holding-or-rolling-back-a-version).
- `.github/workflows/formula-test.yml` — installs, uninstalls, upgrades,
downgrades and pins the formulae on macOS arm64 and Intel. This is what catches
Expand Down
5 changes: 2 additions & 3 deletions Formula/cloudsmith-cli.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# Copyright 2026 Cloudsmith Ltd
#
# Template for the Homebrew formula published to the Homebrew tap
# repository. Rendered by the release workflow (publish-homebrew job);
# do not edit the rendered copy in the tap by hand.
# The Bump Cloudsmith CLI workflow moves this formula to each Cloudsmith CLI
# release. It changes only the version, the sha256 values and the revision.
class CloudsmithCli < Formula
desc "Official Cloudsmith Command-Line Interface"
homepage "https://docs.cloudsmith.com/developer-tools/cli"
Expand Down
99 changes: 99 additions & 0 deletions scripts/bump-cli-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
# Copyright 2026 Cloudsmith Ltd
#
# Move Formula/cloudsmith-cli.rb to a Cloudsmith CLI release.
#
# Reads the sha256 of each platform from the SHA256SUMS asset of the GitHub
# release, and checks each published download against it before it writes the
# formula. Removes the revision line, because a new version starts at revision 0.
# Changes nothing when the formula pins that version or a newer one.
#
# Usage: scripts/bump-cli-version.py VERSION
import hashlib
import importlib
import re
import sys
import time
import urllib.request

render_formula_version = importlib.import_module("render-formula-version")

FORMULA_PATH = "Formula/cloudsmith-cli.rb"
SHA256SUMS_URL = "https://github.com/cloudsmith-io/cloudsmith-cli/releases/download/v{version}/SHA256SUMS"
VERSION = re.compile(r"\d+\.\d+\.\d+")
REVISION_LINE = re.compile(r"^ revision \d+\n", re.MULTILINE)
ARCHIVE_TARGET = re.compile(
r'^\s*url ".*/cloudsmith-[\d.]+-([\w-]+)\.tar\.gz"$', re.MULTILINE
)
SUMS_LINE = re.compile(
r"^([0-9a-f]{64}) cloudsmith-[\d.]+-([\w-]+)\.tar\.gz$", re.MULTILINE
)
DOWNLOAD = re.compile(r'^\s*url "(.+)"\n\s*sha256 "([0-9a-f]{64})"$', re.MULTILINE)
HTTP_TIMEOUT_SECONDS = 60
DOWNLOAD_ATTEMPTS = 10
RETRY_DELAY_SECONDS = 30


def version_key(version):
return tuple(int(part) for part in version.split("."))


def needs_bump(formula, version):
"""Return True when version is newer than the version that formula pins."""
pinned_version = render_formula_version.VERSION_LINE.search(formula).group(1)
return version_key(version) > version_key(pinned_version)


def bump(formula, version, sha256sums):
"""Return formula moved to version."""
shas = {target: sha256 for sha256, target in SUMS_LINE.findall(sha256sums)}
missing = set(ARCHIVE_TARGET.findall(formula)) - shas.keys()
if missing:
raise SystemExit(f"SHA256SUMS has no sha256 for {', '.join(sorted(missing))}")
return render_formula_version.rewrite(REVISION_LINE.sub("", formula), version, shas)


def fetch(url):
"""Return the body at url, and try again while the release syncs."""
for attempt in range(1, DOWNLOAD_ATTEMPTS + 1):
try:
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT_SECONDS) as response:
return response.read()
except OSError as error:
last_error = error
print(f"attempt {attempt}: {url} is not ready", file=sys.stderr)
if attempt < DOWNLOAD_ATTEMPTS:
time.sleep(RETRY_DELAY_SECONDS)
raise SystemExit(f"cannot download {url}: {last_error}")


def verify_downloads(formula):
for url, expected_sha256 in DOWNLOAD.findall(formula):
actual_sha256 = hashlib.sha256(fetch(url)).hexdigest()
if actual_sha256 != expected_sha256:
raise SystemExit(f"{url} has sha256 {actual_sha256}, not {expected_sha256}")
print(f"verified {url}", file=sys.stderr)


def main():
if len(sys.argv) != 2 or not VERSION.fullmatch(sys.argv[1]):
raise SystemExit(f"usage: {sys.argv[0]} MAJOR.MINOR.PATCH")
version = sys.argv[1]

with open(FORMULA_PATH, encoding="utf-8") as formula_file:
formula = formula_file.read()
if not needs_bump(formula, version):
print(
f"{FORMULA_PATH} already pins Cloudsmith CLI {version} or newer",
file=sys.stderr,
)
return
sha256sums = fetch(SHA256SUMS_URL.format(version=version)).decode()
bumped = bump(formula, version, sha256sums)
verify_downloads(bumped)
with open(FORMULA_PATH, "w", encoding="utf-8") as formula_file:
formula_file.write(bumped)


if __name__ == "__main__":
main()
107 changes: 107 additions & 0 deletions scripts/test_bump_cli_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Copyright 2026 Cloudsmith Ltd
import hashlib
import importlib
import io
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

bump_cli_version = importlib.import_module("bump-cli-version")

FORMULA = """\
class CloudsmithCli < Formula
version "1.29.0"
revision 1

if OS.mac?
url "https://dl.cloudsmith.io/cloudsmith-cli-macos-arm64/versions/1.29.0/cloudsmith-1.29.0-macos-arm64.tar.gz"
sha256 "{old}"
else
url "https://dl.cloudsmith.io/cloudsmith-cli-linux-x86_64-gnu/versions/1.29.0/cloudsmith-1.29.0-linux-x86_64-gnu.tar.gz"
sha256 "{old}"
end

livecheck do
url "https://dl.cloudsmith.io/cloudsmith-cli-manifest-macos-arm64/versions/latest/manifest.txt"
end
end
""".replace("{old}", "0" * 64)

SHA256SUMS = f"""\
{"1" * 64} cloudsmith-1.30.0-linux-x86_64-gnu.tar.gz
{"2" * 64} cloudsmith-1.30.0-linux-x86_64-musl.tar.gz
{"3" * 64} cloudsmith-1.30.0-macos-arm64.tar.gz
{"4" * 64} cloudsmith-1.30.0-windows-x86_64.zip
"""


class BumpTest(unittest.TestCase):
def test_moves_version_and_checksums_and_drops_revision(self):
bumped = bump_cli_version.bump(FORMULA, "1.30.0", SHA256SUMS)

self.assertIn(' version "1.30.0"', bumped)
self.assertNotIn("1.29.0", bumped)
self.assertNotIn("revision", bumped)
self.assertIn(
'cloudsmith-1.30.0-macos-arm64.tar.gz"\n sha256 "' + "3" * 64, bumped
)
self.assertIn(
'cloudsmith-1.30.0-linux-x86_64-gnu.tar.gz"\n sha256 "' + "1" * 64,
bumped,
)

def test_needs_bump_only_for_a_newer_version(self):
self.assertTrue(bump_cli_version.needs_bump(FORMULA, "1.30.0"))
self.assertFalse(bump_cli_version.needs_bump(FORMULA, "1.29.0"))
self.assertFalse(bump_cli_version.needs_bump(FORMULA, "1.28.9"))

def test_fails_when_a_formula_target_has_no_checksum(self):
sums = SHA256SUMS.replace("macos-arm64", "macos-x86_64")

with self.assertRaises(SystemExit):
bump_cli_version.bump(FORMULA, "1.30.0", sums)


class VerifyDownloadsTest(unittest.TestCase):
def test_checks_each_download_against_its_checksum(self):
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory, "cloudsmith-1.30.0-macos-arm64.tar.gz")
archive.write_bytes(b"archive")
good_sha256 = hashlib.sha256(b"archive").hexdigest()
formula = f' url "{archive.as_uri()}"\n sha256 "{good_sha256}"\n'

bump_cli_version.verify_downloads(formula)
with self.assertRaises(SystemExit):
bump_cli_version.verify_downloads(
formula.replace(good_sha256, "f" * 64)
)


class FetchTest(unittest.TestCase):
@mock.patch.object(bump_cli_version.time, "sleep")
@mock.patch.object(bump_cli_version.urllib.request, "urlopen")
def test_tries_again_after_a_read_timeout(self, urlopen, _sleep):
urlopen.side_effect = [TimeoutError("read timed out"), io.BytesIO(b"body")]

self.assertEqual(bump_cli_version.fetch("https://example.com"), b"body")


class MainTest(unittest.TestCase):
def test_does_not_fetch_for_a_pinned_version(self):
with tempfile.TemporaryDirectory() as directory:
formula_path = Path(directory, "cloudsmith-cli.rb")
formula_path.write_text(FORMULA, encoding="utf-8")
with mock.patch.multiple(
bump_cli_version,
FORMULA_PATH=str(formula_path),
fetch=mock.Mock(side_effect=AssertionError("fetched")),
), mock.patch.object(sys, "argv", ["bump-cli-version.py", "1.29.0"]):
bump_cli_version.main()

self.assertEqual(formula_path.read_text(encoding="utf-8"), FORMULA)


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