diff --git a/.github/workflows/bump-cli-version.yml b/.github/workflows/bump-cli-version.yml new file mode 100644 index 0000000..7c3a763 --- /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@34f5fd967daaab552a66dd9a890d550c47a39b0c # master + 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/.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/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..255a867 --- /dev/null +++ b/scripts/bump-cli-version.py @@ -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() diff --git a/scripts/test_bump_cli_version.py b/scripts/test_bump_cli_version.py new file mode 100644 index 0000000..87f81d2 --- /dev/null +++ b/scripts/test_bump_cli_version.py @@ -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()