From 837f26984eb13599e945213c01e330a0f6668c40 Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 25 Oct 2026 18:23:38 +0800 Subject: [PATCH 1/2] ci: open Homebrew formula updates after releases --- .github/scripts/test_update_homebrew.py | 98 +++++++++++++++++++++++++ .github/scripts/update_homebrew.py | 91 +++++++++++++++++++++++ .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 32 ++++++++ 4 files changed, 223 insertions(+) create mode 100644 .github/scripts/test_update_homebrew.py create mode 100644 .github/scripts/update_homebrew.py diff --git a/.github/scripts/test_update_homebrew.py b/.github/scripts/test_update_homebrew.py new file mode 100644 index 0000000..211fb45 --- /dev/null +++ b/.github/scripts/test_update_homebrew.py @@ -0,0 +1,98 @@ +import base64 +import unittest +from unittest.mock import patch + +import update_homebrew as updater + +from update_homebrew import update_formula + + +class UpdateFormulaTest(unittest.TestCase): + def setUp(self): + self.hashes = { + "darwin_arm64": "a" * 64, + "linux_amd64": "b" * 64, + "linux_arm64": "c" * 64, + } + self.formula = 'class GorseCli < Formula\n version "0.5.12"\n' + for target in self.hashes: + tag = "v0.5.12" if target == "darwin_arm64" else "v0.5.11" + digest = self.hashes[target] if target == "darwin_arm64" else "0" * 64 + self.formula += ( + f' url "https://github.com/gorse-io/cli/releases/download/{tag}/gorse-cli_{target}",\n' + f' using: :nounzip\n sha256 "{digest}"\n' + ) + self.formula += "end\n" + + def test_updates_both_linux_targets_and_preserves_macos(self): + updated = update_formula(self.formula, "v0.5.12", self.hashes) + self.assertNotIn("v0.5.11", updated) + for target, digest in self.hashes.items(): + self.assertIn(f'/v0.5.12/gorse-cli_{target}', updated) + self.assertIn(f'sha256 "{digest}"', updated) + self.assertEqual(update_formula(updated, "v0.5.12", self.hashes), updated) + + def test_rejects_missing_target(self): + with self.assertRaises(ValueError): + update_formula(self.formula.replace("linux_arm64", "linux_riscv64"), "v0.5.12", self.hashes) + + def test_rejects_duplicate_target(self): + with self.assertRaises(ValueError): + update_formula(self.formula.replace("linux_arm64", "linux_amd64"), "v0.5.12", self.hashes) + + def test_requires_action_to_have_updated_macos_first(self): + with self.assertRaises(ValueError): + update_formula(self.formula.replace("v0.5.12", "v0.5.11"), "v0.5.12", self.hashes) + + def test_rejects_incorrect_macos_hash(self): + with self.assertRaises(ValueError): + update_formula(self.formula.replace("a" * 64, "0" * 64), "v0.5.12", self.hashes) + + def test_rejects_incorrect_formula_version(self): + with self.assertRaises(ValueError): + update_formula(self.formula.replace('version "0.5.12"', 'version "0.5.11"'), "v0.5.12", self.hashes) + + + def test_completes_the_matching_pr_and_reads_back_the_write(self): + state = {"content": self.formula, "writes": 0, "reads": 0} + branch = "update-gorse-cli.rb-123" + + def api(endpoint, *, method="GET", payload=None, paginate=False): + if endpoint.endswith("pulls?state=open&per_page=100"): + self.assertTrue(paginate) + return [[{"title": "unrelated", "head": {}}], [{ + "title": "gorse-cli 0.5.12", + "head": {"ref": branch, "repo": {"full_name": "gorse-io/homebrew-tap"}}, + }]] + self.assertIn("contents/Formula/gorse-cli.rb", endpoint) + if method == "PUT": + assert payload is not None + self.assertEqual(payload["branch"], branch) + self.assertEqual(payload["sha"], "original-blob") + state["content"] = base64.b64decode(payload["content"]).decode() + state["writes"] += 1 + return {} + self.assertIn(f"ref={branch}", endpoint) + state["reads"] += 1 + return {"sha": "original-blob", "content": base64.b64encode(state["content"].encode()).decode()} + + with patch.object(updater, "gh_api", api, create=True), patch.object( + updater, "checksum", lambda tag, target: self.hashes[target], create=True + ): + updater.complete_update("v0.5.12") + self.assertEqual(state["writes"], 1) + self.assertEqual(state["reads"], 2) + self.assertNotIn("v0.5.11", state["content"]) + + def test_rejects_ambiguous_prs_before_any_write(self): + pr = {"title": "gorse-cli 0.5.12", "head": { + "ref": "update-gorse-cli.rb-123", "repo": {"full_name": "gorse-io/homebrew-tap"}, + }} + with patch.object(updater, "gh_api", return_value=[[pr, pr]], create=True) as api: + with self.assertRaises(ValueError): + updater.complete_update("v0.5.12") + self.assertEqual(api.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/update_homebrew.py b/.github/scripts/update_homebrew.py new file mode 100644 index 0000000..856d04d --- /dev/null +++ b/.github/scripts/update_homebrew.py @@ -0,0 +1,91 @@ +"""Complete Linux URLs/checksums in the PR opened by the Homebrew bump action.""" + +import base64 +import hashlib +import json +import os +import re +import subprocess +from urllib.parse import quote +from urllib.request import urlopen + +TARGETS = ("darwin_arm64", "linux_amd64", "linux_arm64") +RELEASE_URL = "https://github.com/gorse-io/cli/releases/download" +PAIR = re.compile( + r'(?P\burl ")(?Phttps://github\.com/gorse-io/cli/releases/download/[^"/]+/gorse-cli_(?Pdarwin_arm64|linux_amd64|linux_arm64))' + r'(?P",\s*using: :nounzip\s*sha256 ")(?P[0-9a-f]{64})(?P")' +) + + +def update_formula(content, tag, hashes): + matches = list(PAIR.finditer(content)) + if sorted(m["target"] for m in matches) != sorted(TARGETS): + raise ValueError("Expected exactly one URL/checksum pair per supported target") + version = tag.removeprefix("v") + if re.findall(r'^ version "([^"]+)"$', content, re.MULTILINE) != [version]: + raise ValueError("The action must update the formula version first") + for target in TARGETS: + if not re.fullmatch(r"[0-9a-f]{64}", hashes[target]): + raise ValueError(f"Invalid checksum for {target}") + macos = next(m for m in matches if m["target"] == "darwin_arm64") + if macos["url"] != f"{RELEASE_URL}/{tag}/gorse-cli_darwin_arm64" or macos["hash"] != hashes["darwin_arm64"]: + raise ValueError("The action must update the macOS URL/checksum first") + return PAIR.sub( + lambda m: f'{m["prefix"]}{RELEASE_URL}/{tag}/gorse-cli_{m["target"]}{m["middle"]}{hashes[m["target"]]}{m["suffix"]}', + content, + ) + + +def gh_api(endpoint, *, method="GET", payload=None, paginate=False): + command = ["gh", "api", endpoint, "--method", method] + if paginate: + command += ["--paginate", "--slurp"] + if payload is not None: + command += ["--input", "-"] + result = subprocess.run( + command, input=json.dumps(payload) if payload is not None else None, + text=True, stdout=subprocess.PIPE, check=True, + ) + return json.loads(result.stdout) + + +def checksum(tag, target): + with urlopen(f"{RELEASE_URL}/{tag}/gorse-cli_{target}", timeout=120) as response: + return hashlib.file_digest(response, "sha256").hexdigest() + + +def complete_update(tag): + if not re.fullmatch(r"v?\d+\.\d+\.\d+", tag): + raise ValueError(f"Expected a stable release tag, got {tag!r}") + repo = "repos/gorse-io/homebrew-tap" + pages = gh_api(f"{repo}/pulls?state=open&per_page=100", paginate=True) + candidates = [ + pr for page in pages for pr in page + if pr["title"] == f"gorse-cli {tag.removeprefix('v')}" + and (pr["head"].get("repo") or {}).get("full_name") == "gorse-io/homebrew-tap" + and pr["head"]["ref"].startswith("update-gorse-cli.rb-") + ] + if len(candidates) != 1: + raise ValueError(f"Expected one action-created formula PR, found {len(candidates)}") + branch = candidates[0]["head"]["ref"] + endpoint = f"{repo}/contents/Formula/gorse-cli.rb" + read_endpoint = f"{endpoint}?ref={quote(branch, safe='')}" + blob = gh_api(read_endpoint) + content = base64.b64decode(blob["content"]).decode() + hashes = {target: checksum(tag, target) for target in TARGETS} + updated = update_formula(content, tag, hashes) + if updated != content: + gh_api(endpoint, method="PUT", payload={ + "branch": branch, + "sha": blob["sha"], + "message": f"gorse-cli {tag.removeprefix('v')}: update Linux binaries", + "content": base64.b64encode(updated.encode()).decode(), + }) + verified = gh_api(read_endpoint) + if base64.b64decode(verified["content"]).decode() != updated: + raise RuntimeError("Remote formula does not match the completed update") + print(f"Verified all platform URLs/checksums on {branch}") + + +if __name__ == "__main__": + complete_update(os.environ["RELEASE_TAG"]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 136180a..8b26b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: with: go-version-file: go.mod - run: go test ./... + - name: Test Homebrew formula updater + run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' install: strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce7fb02..6f6c857 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,38 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + homebrew: + needs: binaries + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: homebrew + cancel-in-progress: false + steps: + - uses: actions/checkout@v5 + - name: Bump Homebrew formula + uses: mislav/bump-homebrew-formula-action@v4.2 + with: + formula-name: gorse-cli + tag-name: ${{ github.event.release.tag_name }} + download-url: https://github.com/gorse-io/cli/releases/download/${{ github.event.release.tag_name }}/gorse-cli_darwin_arm64 + homebrew-tap: gorse-io/homebrew-tap + push-to: gorse-io/homebrew-tap + create-branch: true + create-pullrequest: true + commit-message: gorse-cli {{version}} + env: + GITHUB_TOKEN: ${{ github.token }} + # Token with Contents and Pull requests write access to gorse-io/homebrew-tap. + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # The action updates only the first URL/checksum pair (macOS). + - name: Complete Linux URLs and checksums in the same PR + run: python3 .github/scripts/update_homebrew.py + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} scoop: needs: binaries if: ${{ !github.event.release.prerelease }} From 2d4709f77ddd42ab3e5fb01f0bd3d436e294311e Mon Sep 17 00:00:00 2001 From: zhenghaoz Date: Sun, 25 Oct 2026 19:15:27 +0800 Subject: [PATCH 2/2] ci: limit Homebrew updates to macOS --- .github/scripts/test_update_homebrew.py | 98 ------------------------- .github/scripts/update_homebrew.py | 91 ----------------------- .github/workflows/ci.yml | 2 - .github/workflows/release.yml | 10 +-- 4 files changed, 1 insertion(+), 200 deletions(-) delete mode 100644 .github/scripts/test_update_homebrew.py delete mode 100644 .github/scripts/update_homebrew.py diff --git a/.github/scripts/test_update_homebrew.py b/.github/scripts/test_update_homebrew.py deleted file mode 100644 index 211fb45..0000000 --- a/.github/scripts/test_update_homebrew.py +++ /dev/null @@ -1,98 +0,0 @@ -import base64 -import unittest -from unittest.mock import patch - -import update_homebrew as updater - -from update_homebrew import update_formula - - -class UpdateFormulaTest(unittest.TestCase): - def setUp(self): - self.hashes = { - "darwin_arm64": "a" * 64, - "linux_amd64": "b" * 64, - "linux_arm64": "c" * 64, - } - self.formula = 'class GorseCli < Formula\n version "0.5.12"\n' - for target in self.hashes: - tag = "v0.5.12" if target == "darwin_arm64" else "v0.5.11" - digest = self.hashes[target] if target == "darwin_arm64" else "0" * 64 - self.formula += ( - f' url "https://github.com/gorse-io/cli/releases/download/{tag}/gorse-cli_{target}",\n' - f' using: :nounzip\n sha256 "{digest}"\n' - ) - self.formula += "end\n" - - def test_updates_both_linux_targets_and_preserves_macos(self): - updated = update_formula(self.formula, "v0.5.12", self.hashes) - self.assertNotIn("v0.5.11", updated) - for target, digest in self.hashes.items(): - self.assertIn(f'/v0.5.12/gorse-cli_{target}', updated) - self.assertIn(f'sha256 "{digest}"', updated) - self.assertEqual(update_formula(updated, "v0.5.12", self.hashes), updated) - - def test_rejects_missing_target(self): - with self.assertRaises(ValueError): - update_formula(self.formula.replace("linux_arm64", "linux_riscv64"), "v0.5.12", self.hashes) - - def test_rejects_duplicate_target(self): - with self.assertRaises(ValueError): - update_formula(self.formula.replace("linux_arm64", "linux_amd64"), "v0.5.12", self.hashes) - - def test_requires_action_to_have_updated_macos_first(self): - with self.assertRaises(ValueError): - update_formula(self.formula.replace("v0.5.12", "v0.5.11"), "v0.5.12", self.hashes) - - def test_rejects_incorrect_macos_hash(self): - with self.assertRaises(ValueError): - update_formula(self.formula.replace("a" * 64, "0" * 64), "v0.5.12", self.hashes) - - def test_rejects_incorrect_formula_version(self): - with self.assertRaises(ValueError): - update_formula(self.formula.replace('version "0.5.12"', 'version "0.5.11"'), "v0.5.12", self.hashes) - - - def test_completes_the_matching_pr_and_reads_back_the_write(self): - state = {"content": self.formula, "writes": 0, "reads": 0} - branch = "update-gorse-cli.rb-123" - - def api(endpoint, *, method="GET", payload=None, paginate=False): - if endpoint.endswith("pulls?state=open&per_page=100"): - self.assertTrue(paginate) - return [[{"title": "unrelated", "head": {}}], [{ - "title": "gorse-cli 0.5.12", - "head": {"ref": branch, "repo": {"full_name": "gorse-io/homebrew-tap"}}, - }]] - self.assertIn("contents/Formula/gorse-cli.rb", endpoint) - if method == "PUT": - assert payload is not None - self.assertEqual(payload["branch"], branch) - self.assertEqual(payload["sha"], "original-blob") - state["content"] = base64.b64decode(payload["content"]).decode() - state["writes"] += 1 - return {} - self.assertIn(f"ref={branch}", endpoint) - state["reads"] += 1 - return {"sha": "original-blob", "content": base64.b64encode(state["content"].encode()).decode()} - - with patch.object(updater, "gh_api", api, create=True), patch.object( - updater, "checksum", lambda tag, target: self.hashes[target], create=True - ): - updater.complete_update("v0.5.12") - self.assertEqual(state["writes"], 1) - self.assertEqual(state["reads"], 2) - self.assertNotIn("v0.5.11", state["content"]) - - def test_rejects_ambiguous_prs_before_any_write(self): - pr = {"title": "gorse-cli 0.5.12", "head": { - "ref": "update-gorse-cli.rb-123", "repo": {"full_name": "gorse-io/homebrew-tap"}, - }} - with patch.object(updater, "gh_api", return_value=[[pr, pr]], create=True) as api: - with self.assertRaises(ValueError): - updater.complete_update("v0.5.12") - self.assertEqual(api.call_count, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/update_homebrew.py b/.github/scripts/update_homebrew.py deleted file mode 100644 index 856d04d..0000000 --- a/.github/scripts/update_homebrew.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Complete Linux URLs/checksums in the PR opened by the Homebrew bump action.""" - -import base64 -import hashlib -import json -import os -import re -import subprocess -from urllib.parse import quote -from urllib.request import urlopen - -TARGETS = ("darwin_arm64", "linux_amd64", "linux_arm64") -RELEASE_URL = "https://github.com/gorse-io/cli/releases/download" -PAIR = re.compile( - r'(?P\burl ")(?Phttps://github\.com/gorse-io/cli/releases/download/[^"/]+/gorse-cli_(?Pdarwin_arm64|linux_amd64|linux_arm64))' - r'(?P",\s*using: :nounzip\s*sha256 ")(?P[0-9a-f]{64})(?P")' -) - - -def update_formula(content, tag, hashes): - matches = list(PAIR.finditer(content)) - if sorted(m["target"] for m in matches) != sorted(TARGETS): - raise ValueError("Expected exactly one URL/checksum pair per supported target") - version = tag.removeprefix("v") - if re.findall(r'^ version "([^"]+)"$', content, re.MULTILINE) != [version]: - raise ValueError("The action must update the formula version first") - for target in TARGETS: - if not re.fullmatch(r"[0-9a-f]{64}", hashes[target]): - raise ValueError(f"Invalid checksum for {target}") - macos = next(m for m in matches if m["target"] == "darwin_arm64") - if macos["url"] != f"{RELEASE_URL}/{tag}/gorse-cli_darwin_arm64" or macos["hash"] != hashes["darwin_arm64"]: - raise ValueError("The action must update the macOS URL/checksum first") - return PAIR.sub( - lambda m: f'{m["prefix"]}{RELEASE_URL}/{tag}/gorse-cli_{m["target"]}{m["middle"]}{hashes[m["target"]]}{m["suffix"]}', - content, - ) - - -def gh_api(endpoint, *, method="GET", payload=None, paginate=False): - command = ["gh", "api", endpoint, "--method", method] - if paginate: - command += ["--paginate", "--slurp"] - if payload is not None: - command += ["--input", "-"] - result = subprocess.run( - command, input=json.dumps(payload) if payload is not None else None, - text=True, stdout=subprocess.PIPE, check=True, - ) - return json.loads(result.stdout) - - -def checksum(tag, target): - with urlopen(f"{RELEASE_URL}/{tag}/gorse-cli_{target}", timeout=120) as response: - return hashlib.file_digest(response, "sha256").hexdigest() - - -def complete_update(tag): - if not re.fullmatch(r"v?\d+\.\d+\.\d+", tag): - raise ValueError(f"Expected a stable release tag, got {tag!r}") - repo = "repos/gorse-io/homebrew-tap" - pages = gh_api(f"{repo}/pulls?state=open&per_page=100", paginate=True) - candidates = [ - pr for page in pages for pr in page - if pr["title"] == f"gorse-cli {tag.removeprefix('v')}" - and (pr["head"].get("repo") or {}).get("full_name") == "gorse-io/homebrew-tap" - and pr["head"]["ref"].startswith("update-gorse-cli.rb-") - ] - if len(candidates) != 1: - raise ValueError(f"Expected one action-created formula PR, found {len(candidates)}") - branch = candidates[0]["head"]["ref"] - endpoint = f"{repo}/contents/Formula/gorse-cli.rb" - read_endpoint = f"{endpoint}?ref={quote(branch, safe='')}" - blob = gh_api(read_endpoint) - content = base64.b64decode(blob["content"]).decode() - hashes = {target: checksum(tag, target) for target in TARGETS} - updated = update_formula(content, tag, hashes) - if updated != content: - gh_api(endpoint, method="PUT", payload={ - "branch": branch, - "sha": blob["sha"], - "message": f"gorse-cli {tag.removeprefix('v')}: update Linux binaries", - "content": base64.b64encode(updated.encode()).decode(), - }) - verified = gh_api(read_endpoint) - if base64.b64decode(verified["content"]).decode() != updated: - raise RuntimeError("Remote formula does not match the completed update") - print(f"Verified all platform URLs/checksums on {branch}") - - -if __name__ == "__main__": - complete_update(os.environ["RELEASE_TAG"]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b26b8e..136180a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,6 @@ jobs: with: go-version-file: go.mod - run: go test ./... - - name: Test Homebrew formula updater - run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' install: strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f6c857..9879ee6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,7 @@ jobs: group: homebrew cancel-in-progress: false steps: - - uses: actions/checkout@v5 - - name: Bump Homebrew formula + - name: Bump macOS Homebrew formula uses: mislav/bump-homebrew-formula-action@v4.2 with: formula-name: gorse-cli @@ -42,17 +41,10 @@ jobs: push-to: gorse-io/homebrew-tap create-branch: true create-pullrequest: true - commit-message: gorse-cli {{version}} env: GITHUB_TOKEN: ${{ github.token }} # Token with Contents and Pull requests write access to gorse-io/homebrew-tap. COMMITTER_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - # The action updates only the first URL/checksum pair (macOS). - - name: Complete Linux URLs and checksums in the same PR - run: python3 .github/scripts/update_homebrew.py - env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} scoop: needs: binaries if: ${{ !github.event.release.prerelease }}