From 863f4a26ca058faa8db09e4f68374e0ad6ab4050 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 19 Oct 2026 14:40:22 +0100 Subject: [PATCH 1/2] feat(ENG-14534): bump the formula from the CLI release event Add the Bump Cloudsmith CLI workflow. It runs on the cloudsmith-cli-release event that each CLI release sends, or by hand with a version. It runs scripts/bump-cli-version.py through the open-cli-bump-pr action of the CLI repository, which opens the pull request from cloudsmith-bot and merges it when the checks pass. The script reads the sha256 values from the SHA256SUMS asset of the CLI release and verifies each published download against them. It changes only the version, the sha256 values and the revision of the formula, so a change to the formula in this repository now persists. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bump-cli-version.yml | 40 +++++++++++ .github/workflows/scripts-test.yml | 22 ++++++ CONTRIBUTING.md | 42 +++++------ Formula/cloudsmith-cli.rb | 5 +- scripts/bump-cli-version.py | 98 ++++++++++++++++++++++++++ scripts/test_bump_cli_version.py | 79 +++++++++++++++++++++ 6 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/bump-cli-version.yml create mode 100644 .github/workflows/scripts-test.yml create mode 100755 scripts/bump-cli-version.py create mode 100644 scripts/test_bump_cli_version.py diff --git a/.github/workflows/bump-cli-version.yml b/.github/workflows/bump-cli-version.yml new file mode 100644 index 0000000..2e464ec --- /dev/null +++ b/.github/workflows/bump-cli-version.yml @@ -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@4bf7595eae76a321f8cf58b8221fc74148b25be8 # eng-14534 branch + with: + version: ${{ github.event.client_payload.version || inputs.version }} + bump-command: python3 scripts/bump-cli-version.py "$VERSION" + merge: "true" + token: ${{ secrets.DOWNSTREAM_RELEASE_PUSH_TOKEN }} + ssh-signing-key: ${{ secrets.CLOUDSMITH_BOT_SSH_KEY }} diff --git a/.github/workflows/scripts-test.yml b/.github/workflows/scripts-test.yml new file mode 100644 index 0000000..96ef687 --- /dev/null +++ b/.github/workflows/scripts-test.yml @@ -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' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 780e83a..af38760 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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@.rb` — pinned rollback targets. The release - job does not touch these, so they persist across releases. See the +- `Formula/cloudsmith-cli@.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 diff --git a/Formula/cloudsmith-cli.rb b/Formula/cloudsmith-cli.rb index cc1d20d..760965a 100644 --- a/Formula/cloudsmith-cli.rb +++ b/Formula/cloudsmith-cli.rb @@ -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" diff --git a/scripts/bump-cli-version.py b/scripts/bump-cli-version.py new file mode 100755 index 0000000..5c4a706 --- /dev/null +++ b/scripts/bump-cli-version.py @@ -0,0 +1,98 @@ +#!/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.error +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 bump(formula, version, sha256sums): + """Return formula moved to version, or None when it pins version or a newer one.""" + pinned_version = render_formula_version.VERSION_LINE.search(formula).group(1) + if version_key(version) <= version_key(pinned_version): + return None + + 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 urllib.error.URLError 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() + sha256sums = fetch(SHA256SUMS_URL.format(version=version)).decode() + bumped = bump(formula, version, sha256sums) + if bumped is None: + print( + f"{FORMULA_PATH} already pins Cloudsmith CLI {version} or newer", + file=sys.stderr, + ) + return + verify_downloads(bumped) + with open(FORMULA_PATH, "w", encoding="utf-8") as formula_file: + formula_file.write(bumped) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_bump_cli_version.py b/scripts/test_bump_cli_version.py new file mode 100644 index 0000000..f564c2f --- /dev/null +++ b/scripts/test_bump_cli_version.py @@ -0,0 +1,79 @@ +# Copyright 2026 Cloudsmith Ltd +import hashlib +import importlib +import tempfile +import unittest +from pathlib import Path + +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_keeps_a_formula_that_pins_the_version_or_a_newer_one(self): + self.assertIsNone(bump_cli_version.bump(FORMULA, "1.29.0", SHA256SUMS)) + self.assertIsNone(bump_cli_version.bump(FORMULA, "1.28.9", SHA256SUMS)) + + 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) + ) + + +if __name__ == "__main__": + unittest.main() From 246a7a0ef31e30ce1c622329f3d733d4f4441384 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 19 Oct 2026 17:02:47 +0100 Subject: [PATCH 2/2] fix(ENG-14534): skip the fetch for a pinned version and ignore bytecode - Compare the versions before the script downloads SHA256SUMS. A re-run for the pinned version or an older one then does not need the release assets. - Ignore __pycache__/. The import of render-formula-version writes bytecode, and the bump action stages all changes with git add --all. - Try the download again on all OSError values. A read timeout raises TimeoutError, which is not a URLError. - Pin open-cli-bump-pr to the merge commit of cloudsmith-io/cloudsmith-cli#451. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/bump-cli-version.yml | 2 +- .gitignore | 1 + scripts/bump-cli-version.py | 19 +++++++------- scripts/test_bump_cli_version.py | 34 +++++++++++++++++++++++--- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/.github/workflows/bump-cli-version.yml b/.github/workflows/bump-cli-version.yml index 2e464ec..7c3a763 100644 --- a/.github/workflows/bump-cli-version.yml +++ b/.github/workflows/bump-cli-version.yml @@ -31,7 +31,7 @@ jobs: timeout-minutes: 30 steps: - name: Open the bump pull request - uses: cloudsmith-io/cloudsmith-cli/.github/actions/open-cli-bump-pr@4bf7595eae76a321f8cf58b8221fc74148b25be8 # eng-14534 branch + 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" diff --git a/.gitignore b/.gitignore index affd670..8a9e3d9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ temp/ *.tmp *.tar.gz *.pyz +__pycache__/ # OS-generated files .DS_Store diff --git a/scripts/bump-cli-version.py b/scripts/bump-cli-version.py index 5c4a706..255a867 100755 --- a/scripts/bump-cli-version.py +++ b/scripts/bump-cli-version.py @@ -14,7 +14,6 @@ import re import sys import time -import urllib.error import urllib.request render_formula_version = importlib.import_module("render-formula-version") @@ -39,12 +38,14 @@ def version_key(version): return tuple(int(part) for part in version.split(".")) -def bump(formula, version, sha256sums): - """Return formula moved to version, or None when it pins version or a newer one.""" +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) - if version_key(version) <= version_key(pinned_version): - return None + 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: @@ -58,7 +59,7 @@ def fetch(url): try: with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT_SECONDS) as response: return response.read() - except urllib.error.URLError as error: + except OSError as error: last_error = error print(f"attempt {attempt}: {url} is not ready", file=sys.stderr) if attempt < DOWNLOAD_ATTEMPTS: @@ -81,14 +82,14 @@ def main(): with open(FORMULA_PATH, encoding="utf-8") as formula_file: formula = formula_file.read() - sha256sums = fetch(SHA256SUMS_URL.format(version=version)).decode() - bumped = bump(formula, version, sha256sums) - if bumped is None: + 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) diff --git a/scripts/test_bump_cli_version.py b/scripts/test_bump_cli_version.py index f564c2f..87f81d2 100644 --- a/scripts/test_bump_cli_version.py +++ b/scripts/test_bump_cli_version.py @@ -1,9 +1,12 @@ # 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") @@ -49,9 +52,10 @@ def test_moves_version_and_checksums_and_drops_revision(self): bumped, ) - def test_keeps_a_formula_that_pins_the_version_or_a_newer_one(self): - self.assertIsNone(bump_cli_version.bump(FORMULA, "1.29.0", SHA256SUMS)) - self.assertIsNone(bump_cli_version.bump(FORMULA, "1.28.9", SHA256SUMS)) + 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") @@ -75,5 +79,29 @@ def test_checks_each_download_against_its_checksum(self): ) +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()