From 12f1f4bc1da33d1aefc102ba815d834dc8d061e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 21:23:38 +0000 Subject: [PATCH 1/3] Add a Homebrew cask and update it after each release Users can tap this repo and `brew install --cask convt` for the signed Apple silicon app. A non-blocking Release job rewrites the cask from release-manifest.json so a tap failure cannot fail publication. Co-authored-by: Leo --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 28 ++++ Casks/convt.rb | 32 ++++ README.md | 11 +- apps/web/src/routes/_site/download.tsx | 14 ++ apps/web/test/unit/download.test.tsx | 2 + docs/release.md | 2 +- packaging/release/convt.rb.in | 29 +++- scripts/release/check-homebrew-cask.sh | 24 +++ scripts/release/homebrew_cask.py | 199 ++++++++++++++++++++++++ scripts/release/templates.py | 17 +- scripts/release/test_homebrew.py | 184 ++++++++++++++++++++++ scripts/release/update-homebrew-cask.py | 95 +++++++++++ 13 files changed, 622 insertions(+), 18 deletions(-) create mode 100644 Casks/convt.rb create mode 100755 scripts/release/check-homebrew-cask.sh create mode 100644 scripts/release/homebrew_cask.py create mode 100644 scripts/release/test_homebrew.py create mode 100755 scripts/release/update-homebrew-cask.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88192fee..9e6ee830 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: sudo apt-get update sudo apt-get install -y libxcb1-dev libx11-xcb-dev libxkbcommon-dev libxkbcommon-x11-dev \ libwayland-dev libvulkan-dev libfontconfig-dev libfreetype-dev libasound2-dev libzstd-dev + - name: Homebrew cask style + if: runner.os == 'macOS' && matrix.suite == 'core' + run: bash scripts/release/check-homebrew-cask.sh - run: cargo fmt --all --check if: runner.os == 'Linux' - name: Clippy (core) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edc8e66e..3c2ef9a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,3 +180,31 @@ jobs: fi gh release create "v$RELEASE_VERSION" release/* --target "$GITHUB_SHA" \ --title "convt $RELEASE_VERSION" --notes-file "$RUNNER_TEMP/release-notes.md" + # Never blocks publication. Missing tap repo, branch protection, or a + # stale sha256 fetch must not fail the Release workflow. + homebrew: + name: Update Homebrew cask + if: always() && needs.preflight.outputs.publish == 'true' && needs.publish.result == 'success' + needs: [preflight, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + continue-on-error: true + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: main + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Bump Casks/convt.rb from the published manifest + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + python3 scripts/release/update-homebrew-cask.py \ + --cask Casks/convt.rb \ + --from-manifest-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${RELEASE_VERSION}/release-manifest.json" \ + --commit --push --tap opencoredev/homebrew-tap diff --git a/Casks/convt.rb b/Casks/convt.rb new file mode 100644 index 00000000..297b7f55 --- /dev/null +++ b/Casks/convt.rb @@ -0,0 +1,32 @@ +cask "convt" do + version "0.2.0" + sha256 "8fc47f8b9873adbf4ad6df1db9e050d74205c26cff7e8c7e919ce0d398ae2ea3" + + url "https://github.com/opencoredev/convt/releases/download/v#{version}/convt-macos-arm64.dmg", + verified: "github.com/opencoredev/convt/" + name "convt" + desc "Convert files locally without uploading" + homepage "https://convt.app/" + + livecheck do + url :url + strategy :github_latest + end + + depends_on arch: :arm64 + depends_on macos: :ventura + + app "convt.app" + binary "#{appdir}/convt.app/Contents/MacOS/convt" + + uninstall quit: "app.convt.desktop" + + zap trash: [ + "~/Library/Application Support/convt", + "~/Library/Caches/app.convt.desktop", + "~/Library/HTTPStorages/app.convt.desktop", + "~/Library/Logs/convt", + "~/Library/Preferences/app.convt.desktop.plist", + "~/Library/Saved Application State/app.convt.desktop.savedState", + ] +end diff --git a/README.md b/README.md index 73221f2d..50ccd313 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,16 @@ Local file conversion for your own machine. Right-click a file, pick a format, a ## Install -The CLI works from this repo today. Ordinary source builds are unrestricted. Desktop builds will appear on the [download page](https://convt.app/download) (Linux first). Packaged builds start a 7-day trial on the first conversion. +macOS (Apple silicon) with Homebrew: + +```bash +brew tap opencoredev/convt https://github.com/opencoredev/convt +brew install --cask convt +``` + +That installs the signed app and puts the `convt` CLI on your `PATH`. Linux and Windows builds are on the [download page](https://convt.app/download). There is no Linux Homebrew formula yet; use the `.deb`, `.rpm` or AppImage. + +The CLI also works from this repo. Ordinary source builds are unrestricted. Packaged builds start a 7-day trial on the first conversion. ```bash bun run setup diff --git a/apps/web/src/routes/_site/download.tsx b/apps/web/src/routes/_site/download.tsx index 9c0a0ea0..062b4925 100644 --- a/apps/web/src/routes/_site/download.tsx +++ b/apps/web/src/routes/_site/download.tsx @@ -133,6 +133,7 @@ function Recommended({ os }: { os: Os }) { {slot?.artifact && } + {os === "macos" && }

Not your system?{" "} @@ -195,6 +196,19 @@ function PlatformCard({ os, current }: { os: Os; current: boolean }) { ))} + {os === "macos" && } ); } + +function HomebrewInstall() { + return ( +

+

Or install with Homebrew (Apple silicon):

+
+        {`brew tap opencoredev/convt https://github.com/opencoredev/convt
+brew install --cask convt`}
+      
+
+ ); +} diff --git a/apps/web/test/unit/download.test.tsx b/apps/web/test/unit/download.test.tsx index 6fedd997..4cf8a663 100644 --- a/apps/web/test/unit/download.test.tsx +++ b/apps/web/test/unit/download.test.tsx @@ -39,4 +39,6 @@ test("/download has no checksum or source Coming soon sections", () => { expect(page).not.toContain("verify-title"); expect(page).toContain("DownloadButton"); expect(page).toContain("For your computer"); + expect(page).toContain("brew install --cask convt"); + expect(page).toContain("brew tap opencoredev/convt"); }); diff --git a/docs/release.md b/docs/release.md index 247194a5..ff2075af 100644 --- a/docs/release.md +++ b/docs/release.md @@ -51,7 +51,7 @@ The envelope contains `payload` and `signature`, both unpadded base64url. Payloa ## Signing and publishing -Compare unsigned artifacts first. `scripts/release/sign-linux.sh UNSIGNED SIGNED` signs copies with `debsigs` and `rpmsign`, using `CONVT_REPO_SIGNING_KEY_ID`. Regenerate checksums and manifests from signed copies because signatures change bytes. The apt layout has pool packages, Packages, Packages.gz and Release; sign Release into InRelease and Release.gpg. The dnf template signs packages, creates deterministic repodata with `createrepo_c`, then signs repomd.xml. Homebrew and winget manifests are rendered only when the matching Mac or Windows artifact exists. Their unfilled templates live in `packaging/release`. +Compare unsigned artifacts first. `scripts/release/sign-linux.sh UNSIGNED SIGNED` signs copies with `debsigs` and `rpmsign`, using `CONVT_REPO_SIGNING_KEY_ID`. Regenerate checksums and manifests from signed copies because signatures change bytes. The apt layout has pool packages, Packages, Packages.gz and Release; sign Release into InRelease and Release.gpg. The dnf template signs packages, creates deterministic repodata with `createrepo_c`, then signs repomd.xml. Homebrew and winget manifests are rendered only when the matching Mac or Windows artifact exists. Their unfilled templates live in `packaging/release`. The tap users install is `Casks/convt.rb` in this repository (`brew tap opencoredev/convt https://github.com/opencoredev/convt`). After a successful GitHub release, the `Update Homebrew cask` job rewrites that file from `release-manifest.json` and pushes it. The job is `continue-on-error: true`, so a tap or branch-protection failure never blocks publication. Set repository secret `HOMEBREW_TAP_TOKEN` (a PAT that can write `opencoredev/homebrew-tap`) to also publish `brew install --cask opencoredev/tap/convt`. `bun run release:upload --dry-run VERSION_DIRECTORY` makes no network writes. `--upload` is a separate operation, rejects failed source gates and mismatched artifact bytes, and needs a bucket created by Leo plus account ID and scoped R2 S3 credentials. Account and bucket management use the `cf` CLI. Object transfer uses the S3-compatible endpoint and an atomic If-None-Match precondition, with a 5 GiB single-object limit, instead of the cf REST object's 300 MB limit. Split larger corresponding-source deliveries before publication. This upload path has not been exercised against an account. diff --git a/packaging/release/convt.rb.in b/packaging/release/convt.rb.in index 2b08d70d..acb0377b 100644 --- a/packaging/release/convt.rb.in +++ b/packaging/release/convt.rb.in @@ -1,11 +1,32 @@ cask "convt" do version "@version@" sha256 "@macos-arm64.dmg.sha256@" - url "@macos-arm64.dmg.url@" + + url "https://github.com/opencoredev/convt/releases/download/v#{version}/convt-macos-arm64.dmg", + verified: "github.com/opencoredev/convt/" name "convt" - desc "Local file conversion" - homepage "https://convt.app" + desc "Convert files locally without uploading" + homepage "https://convt.app/" + + livecheck do + url :url + strategy :github_latest + end + depends_on arch: :arm64 - depends_on macos: ">= :ventura" + depends_on macos: :ventura + app "convt.app" + binary "#{appdir}/convt.app/Contents/MacOS/convt" + + uninstall quit: "app.convt.desktop" + + zap trash: [ + "~/Library/Application Support/convt", + "~/Library/Caches/app.convt.desktop", + "~/Library/HTTPStorages/app.convt.desktop", + "~/Library/Logs/convt", + "~/Library/Preferences/app.convt.desktop.plist", + "~/Library/Saved Application State/app.convt.desktop.savedState", + ] end diff --git a/scripts/release/check-homebrew-cask.sh b/scripts/release/check-homebrew-cask.sh new file mode 100755 index 00000000..1effc90f --- /dev/null +++ b/scripts/release/check-homebrew-cask.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Validate Casks/convt.rb with `brew style` and `brew audit`. Homebrew only +# accepts casks that live in a tap, so this copies the file into a throwaway +# local tap and removes it afterwards. +set -euo pipefail +root=$(cd "$(dirname "$0")/../.." && pwd) +cask="$root/Casks/convt.rb" +test -f "$cask" +command -v brew >/dev/null + +tap=convt/cask-audit +# brew tap-new writes into $(brew --repository)/Library/Taps/convt/homebrew-cask-audit +if brew tap-info "$tap" >/dev/null 2>&1; then + brew untap "$tap" +fi +brew tap-new --no-git "$tap" >/dev/null +repo=$(brew --repository "$tap") +mkdir -p "$repo/Casks" +cp "$cask" "$repo/Casks/convt.rb" +cleanup() { brew untap "$tap" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +brew style --cask "$tap/convt" +brew audit --cask "$tap/convt" diff --git a/scripts/release/homebrew_cask.py b/scripts/release/homebrew_cask.py new file mode 100644 index 00000000..6c95fc07 --- /dev/null +++ b/scripts/release/homebrew_cask.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Read and bump the Homebrew cask. Casks/convt.rb is the source of truth.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import ssl +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +REPO = Path(__file__).resolve().parents[2] +DEFAULT_CASK = REPO / "Casks" / "convt.rb" +TAP_REPO = "opencoredev/homebrew-tap" +VERSION_RE = re.compile(r'^(\s*version\s+")([^"]+)(")\s*$', re.M) +SHA_RE = re.compile(r'^(\s*sha256(?:\s+arm:)?\s+")([0-9a-f]{64})(")\s*$', re.M) +VERSION_VALUE = re.compile(r"^\d+\.\d+\.\d+$") +SHA_VALUE = re.compile(r"^[0-9a-f]{64}$") +REQUIRED = ( + 'cask "convt" do', + "livecheck do", + "strategy :github_latest", + "depends_on arch: :arm64", + "depends_on macos:", + 'app "convt.app"', + "zap trash:", + "convt-macos-arm64.dmg", +) + +TAP_README = """# opencoredev Homebrew tap + +```bash +brew install --cask opencoredev/tap/convt +``` + +Apple silicon only. This tap is updated after each +[convt](https://github.com/opencoredev/convt) GitHub release. +""" + + +def validate_cask(text: str) -> None: + missing = [item for item in REQUIRED if item not in text] + if missing: + raise ValueError("cask is missing: " + ", ".join(missing)) + if VERSION_RE.search(text) is None: + raise ValueError("cask needs a version stanza") + if SHA_RE.search(text) is None: + raise ValueError("cask needs a sha256 stanza") + + +def bump_cask(text: str, *, version: str, sha256: str) -> str: + if not VERSION_VALUE.fullmatch(version): + raise ValueError(f"invalid version: {version}") + if not SHA_VALUE.fullmatch(sha256): + raise ValueError(f"invalid sha256: {sha256}") + validate_cask(text) + bumped, count = VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) + if count != 1: + raise ValueError("expected exactly one version stanza") + bumped, count = SHA_RE.subn(rf"\g<1>{sha256}\g<3>", bumped, count=1) + if count != 1: + raise ValueError("expected exactly one sha256 stanza") + validate_cask(bumped) + return bumped + + +def dmg_from_manifest(manifest: dict) -> tuple[str, str]: + builds = manifest.get("builds") or [] + if not builds: + raise ValueError("manifest has no builds") + build = builds[-1] + version = build.get("version") + if not isinstance(version, str) or not VERSION_VALUE.fullmatch(version): + raise ValueError("manifest build is missing a version") + for artifact in build.get("artifacts") or []: + if artifact.get("platform") == "macos-arm64" and artifact.get("kind") == "dmg": + digest = artifact.get("sha256") + if not isinstance(digest, str) or not SHA_VALUE.fullmatch(digest): + raise ValueError("macos-arm64 dmg has a bad sha256") + return version, digest + raise ValueError("manifest has no macos-arm64 dmg") + + +def fetch_json(url: str, *, attempts: int = 8, delay: float = 5.0) -> dict: + if not url.startswith("https://"): + raise ValueError("manifest URL must be https") + last: Exception | None = None + context = ssl.create_default_context() + for attempt in range(attempts): + try: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, context=context, timeout=30) as response: + return json.loads(response.read().decode()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as error: + last = error + if attempt + 1 < attempts: + time.sleep(delay) + raise ValueError(f"could not fetch {url}: {last}") from last + + +def write_cask(path: Path, text: str) -> bool: + previous = path.read_text() if path.exists() else "" + if previous == text: + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return True + + +def git_env(token: str | None = None) -> dict[str, str]: + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + if token: + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = "http.https://github.com/.extraheader" + env["GIT_CONFIG_VALUE_0"] = f"AUTHORIZATION: bearer {token}" + return env + + +def run_git(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> str: + merged = git_env() + if env: + merged.update(env) + return subprocess.check_output(["git", *args], cwd=cwd, env=merged, text=True).strip() + + +def commit_if_changed(repo: Path, path: Path, message: str) -> bool: + run_git(["add", "--", str(path.relative_to(repo))], cwd=repo) + staged = run_git(["diff", "--cached", "--name-only", "--", str(path.relative_to(repo))], cwd=repo) + if not staged: + return False + run_git( + [ + "-c", + "user.name=github-actions[bot]", + "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", + "commit", + "-m", + message, + ], + cwd=repo, + ) + return True + + +def push_head(repo: Path, remote: str = "origin") -> None: + branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo) + if branch in {"HEAD", ""}: + raise ValueError("refusing to push a detached HEAD") + subprocess.check_call(["git", "push", remote, f"HEAD:refs/heads/{branch}"], cwd=repo) + + +def publish_tap(cask: Path, *, tap: str, token: str, version: str) -> None: + if "/" not in tap: + raise ValueError(f"tap must be owner/repo: {tap}") + work = Path(tempfile.mkdtemp(prefix="convt-homebrew-tap-")) + env = git_env(token) + try: + try: + subprocess.check_call( + ["git", "clone", "--depth", "1", f"https://github.com/{tap}.git", str(work / "tap")], + env=env, + ) + except subprocess.CalledProcessError: + raise ValueError( + f"could not clone {tap}; create the public repo and set HOMEBREW_TAP_TOKEN" + ) from None + repo = work / "tap" + dest = repo / "Casks" / "convt.rb" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(cask.read_text()) + readme = repo / "README.md" + if not readme.exists(): + readme.write_text(TAP_README) + run_git(["add", "Casks/convt.rb", "README.md"], cwd=repo, env=env) + if not run_git(["diff", "--cached", "--name-only"], cwd=repo, env=env): + return + run_git( + [ + "-c", + "user.name=github-actions[bot]", + "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", + "commit", + "-m", + f"convt {version}", + ], + cwd=repo, + env=env, + ) + subprocess.check_call(["git", "push", "origin", "HEAD"], cwd=repo, env=env) + finally: + shutil.rmtree(work, ignore_errors=True) diff --git a/scripts/release/templates.py b/scripts/release/templates.py index 42d400ca..178aa456 100644 --- a/scripts/release/templates.py +++ b/scripts/release/templates.py @@ -25,18 +25,11 @@ def copy_artifact(a,dest): return p mac=artifacts.get(('macos-arm64','dmg')) if mac: - text(Path('homebrew/convt.rb'),f'''cask "convt" do - version "{version}" - sha256 "{mac['sha256']}" - url "{mac['url']}" - name "convt" - desc "Local file conversion" - homepage "https://convt.app" - depends_on macos: ">= :ventura" - depends_on arch: :arm64 - app "convt.app" -end -''') + from importlib.util import module_from_spec, spec_from_file_location + homebrew_spec=spec_from_file_location('homebrew_cask',Path(__file__).with_name('homebrew_cask.py')) + homebrew=module_from_spec(homebrew_spec); homebrew_spec.loader.exec_module(homebrew) + source=Path(__file__).resolve().parents[2]/'Casks'/'convt.rb' + text(Path('homebrew/convt.rb'),homebrew.bump_cask(source.read_text(),version=version,sha256=mac['sha256'])) windows=artifacts.get(('windows-x86_64','msi')) or artifacts.get(('windows-x86_64','exe')) if windows: text(Path('winget/Convt.Convt.installer.yaml'),f'''PackageIdentifier: Convt.Convt diff --git a/scripts/release/test_homebrew.py b/scripts/release/test_homebrew.py new file mode 100644 index 00000000..7c247c30 --- /dev/null +++ b/scripts/release/test_homebrew.py @@ -0,0 +1,184 @@ +"""Homebrew cask shape, bumping, and the post-release updater.""" +from importlib.util import module_from_spec, spec_from_file_location +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] + +spec = spec_from_file_location("homebrew_cask", HERE / "homebrew_cask.py") +homebrew = module_from_spec(spec) +spec.loader.exec_module(homebrew) + +CASK = (REPO / "Casks" / "convt.rb").read_text() + + +def manifest(version="0.2.1", sha256="b" * 64): + return { + "builds": [ + { + "version": version, + "artifacts": [ + { + "platform": "macos-arm64", + "kind": "dmg", + "url": f"https://github.com/opencoredev/convt/releases/download/v{version}/convt-macos-arm64.dmg", + "sha256": sha256, + } + ], + } + ] + } + + +class CaskFileTests(unittest.TestCase): + def test_committed_cask_is_valid(self): + homebrew.validate_cask(CASK) + self.assertIn('version "0.2.0"', CASK) + self.assertIn("8fc47f8b9873adbf4ad6df1db9e050d74205c26cff7e8c7e919ce0d398ae2ea3", CASK) + self.assertIn("convt-macos-arm64.dmg", CASK) + self.assertIn("depends_on arch: :arm64", CASK) + self.assertIn("depends_on macos: :ventura", CASK) + self.assertNotIn("x86_64", CASK) + self.assertNotIn("intel", CASK) + self.assertNotIn('arch arm:', CASK) + self.assertIn("~/Library/Application Support/convt", CASK) + for line in CASK.splitlines(): + if line and not line.startswith("cask ") and line != "end": + self.assertTrue(len(line) - len(line.lstrip(" ")) in {2, 4, 6}, line) + + def test_check_cli(self): + result = subprocess.run( + [sys.executable, str(HERE / "update-homebrew-cask.py"), "--cask", str(REPO / "Casks" / "convt.rb"), "--check"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("ok ", result.stdout) + + +class BumpTests(unittest.TestCase): + def test_replaces_version_and_sha_only(self): + bumped = homebrew.bump_cask(CASK, version="0.2.1", sha256="c" * 64) + self.assertIn('version "0.2.1"', bumped) + self.assertIn('sha256 "' + "c" * 64 + '"', bumped) + self.assertNotIn('version "0.2.0"', bumped) + self.assertIn("livecheck do", bumped) + self.assertIn("zap trash:", bumped) + self.assertEqual(bumped.count("cask "), 1) + + def test_rejects_bad_values(self): + with self.assertRaisesRegex(ValueError, "invalid version"): + homebrew.bump_cask(CASK, version="v0.2.1", sha256="c" * 64) + with self.assertRaisesRegex(ValueError, "invalid sha256"): + homebrew.bump_cask(CASK, version="0.2.1", sha256="C" * 64) + + def test_manifest_reads_latest_mac_dmg(self): + version, digest = homebrew.dmg_from_manifest(manifest()) + self.assertEqual(version, "0.2.1") + self.assertEqual(digest, "b" * 64) + with self.assertRaisesRegex(ValueError, "macos-arm64 dmg"): + homebrew.dmg_from_manifest({"builds": [{"version": "1.0.0", "artifacts": []}]}) + + def test_cli_rewrites_a_copy(self): + with tempfile.TemporaryDirectory(prefix="convt-cask-") as tmp: + dest = Path(tmp) / "Casks" / "convt.rb" + dest.parent.mkdir() + dest.write_text(CASK) + payload = Path(tmp) / "release-manifest.json" + payload.write_text(json.dumps(manifest("0.3.0", "d" * 64))) + result = subprocess.run( + [ + sys.executable, + str(HERE / "update-homebrew-cask.py"), + "--cask", + str(dest), + "--manifest", + str(payload), + "--skip-tap", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + text = dest.read_text() + self.assertIn('version "0.3.0"', text) + self.assertIn('sha256 "' + "d" * 64 + '"', text) + self.assertIn("updated ", result.stdout) + + +class GuardTests(unittest.TestCase): + def test_http_manifest_url_is_refused(self): + with self.assertRaisesRegex(ValueError, "https"): + homebrew.fetch_json("http://example.test/release-manifest.json", attempts=1, delay=0) + + def test_missing_tap_error_omits_the_token(self): + token = "SECRETTOKENVALUE" + with self.assertRaises(ValueError) as raised: + homebrew.publish_tap( + REPO / "Casks" / "convt.rb", + tap="opencoredev/this-tap-does-not-exist-3756", + token=token, + version="0.2.0", + ) + self.assertIn("HOMEBREW_TAP_TOKEN", str(raised.exception)) + self.assertNotIn(token, str(raised.exception)) + + def test_release_workflow_cannot_block_publish(self): + text = (REPO / ".github" / "workflows" / "release.yml").read_text() + self.assertIn("continue-on-error: true", text) + self.assertIn("Update Homebrew cask", text) + self.assertIn("HOMEBREW_TAP_TOKEN", text) + self.assertLess(text.index("gh release create"), text.index("Update Homebrew cask")) + + +class TemplateTests(unittest.TestCase): + def test_templates_emit_the_bumped_cask(self): + with tempfile.TemporaryDirectory(prefix="convt-templates-") as tmp: + root = Path(tmp) + (root / "convt-macos-arm64.dmg").write_bytes(b"dmg") + digest = __import__("hashlib").sha256(b"dmg").hexdigest() + payload = { + "distribution_ready": True, + "builds": [ + { + "version": "0.4.0", + "artifacts": [ + { + "platform": "macos-arm64", + "kind": "dmg", + "url": "https://github.com/opencoredev/convt/releases/download/v0.4.0/convt-macos-arm64.dmg", + "size": 3, + "sha256": digest, + } + ], + } + ], + } + manifest_path = root / "release-manifest.json" + manifest_path.write_text(json.dumps(payload)) + out = root / "repositories" + result = subprocess.run( + [sys.executable, str(HERE / "templates.py"), str(manifest_path), str(out)], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = (out / "homebrew" / "convt.rb").read_text() + self.assertIn('version "0.4.0"', rendered) + self.assertIn(digest, rendered) + self.assertIn("livecheck do", rendered) + self.assertIn("zap trash:", rendered) + status = json.loads((out / "STATUS.json").read_text()) + self.assertEqual(status["homebrew"], "rendered") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/update-homebrew-cask.py b/scripts/release/update-homebrew-cask.py new file mode 100755 index 00000000..ea1110a0 --- /dev/null +++ b/scripts/release/update-homebrew-cask.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Bump Casks/convt.rb from a release manifest and optionally push the tap. + +A failing push never has to stop a release: the Release workflow runs this +job with continue-on-error. Local use: + + python3 scripts/release/update-homebrew-cask.py --cask Casks/convt.rb --check + python3 scripts/release/update-homebrew-cask.py --cask Casks/convt.rb \\ + --version 0.2.0 --sha256 <64-hex> +""" +from __future__ import annotations + +import argparse +from importlib.util import module_from_spec, spec_from_file_location +import json +import os +from pathlib import Path +import sys +import traceback + +spec = spec_from_file_location("homebrew_cask", Path(__file__).with_name("homebrew_cask.py")) +homebrew = module_from_spec(spec) +spec.loader.exec_module(homebrew) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cask", type=Path, default=homebrew.DEFAULT_CASK) + parser.add_argument("--version") + parser.add_argument("--sha256") + parser.add_argument("--manifest", type=Path, help="Local release-manifest.json") + parser.add_argument("--from-manifest-url", help="HTTPS URL of release-manifest.json") + parser.add_argument("--check", action="store_true", help="Validate the cask and exit") + parser.add_argument("--commit", action="store_true") + parser.add_argument("--push", action="store_true") + parser.add_argument("--tap", default=homebrew.TAP_REPO) + parser.add_argument("--skip-tap", action="store_true") + return parser.parse_args(argv) + + +def version_and_sha(args: argparse.Namespace) -> tuple[str, str]: + if args.manifest is not None: + return homebrew.dmg_from_manifest(json.loads(args.manifest.read_text())) + if args.from_manifest_url: + return homebrew.dmg_from_manifest(homebrew.fetch_json(args.from_manifest_url)) + if args.version and args.sha256: + return args.version, args.sha256 + raise ValueError("pass --version and --sha256, --manifest, or --from-manifest-url") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + cask = args.cask.resolve() + try: + text = cask.read_text() + homebrew.validate_cask(text) + if args.check: + print(f"ok {cask}") + return 0 + version, sha256 = version_and_sha(args) + bumped = homebrew.bump_cask(text, version=version, sha256=sha256) + changed = homebrew.write_cask(cask, bumped) + print(f"{'updated' if changed else 'unchanged'} {cask} {version} {sha256}") + errors: list[str] = [] + repo = homebrew.REPO if cask.is_relative_to(homebrew.REPO) else cask.parent.parent + if changed and args.commit: + message = f"chore(homebrew): bump convt to {version}" + if homebrew.commit_if_changed(repo, cask, message): + print(f"committed {cask.relative_to(repo)}") + if args.push: + try: + homebrew.push_head(repo) + print("pushed this repository") + except Exception as error: # noqa: BLE001 — report, then still try the tap + errors.append(f"push this repository: {error}") + token = os.environ.get("HOMEBREW_TAP_TOKEN", "").strip() + if not args.skip_tap and token: + try: + homebrew.publish_tap(cask, tap=args.tap, token=token, version=version) + print(f"published {args.tap}") + except Exception as error: # noqa: BLE001 — tap is optional + errors.append(f"publish tap: {error}") + elif not args.skip_tap: + print("HOMEBREW_TAP_TOKEN unset; skipped opencoredev/homebrew-tap") + if errors: + print("\n".join(errors), file=sys.stderr) + return 1 + return 0 + except Exception: + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From d2d988ec089908f773765a8ce83a19f35c28f8e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 23:21:49 +0000 Subject: [PATCH 2/3] Fix Homebrew cask tests and retry a failed tap push Derive the committed cask version and digest in tests so a later release bump does not fail CI. Always honor --push even when the cask file already matches, and keep tap publication independent of the source-repo commit. Mock the missing-tap clone. Co-authored-by: Leo --- apps/web/src/components/site/download.tsx | 12 +++ apps/web/src/routes/_site/download.tsx | 14 +-- apps/web/test/unit/download.test.tsx | 11 ++- scripts/release/homebrew_cask.py | 68 ++++++++------- scripts/release/templates.py | 2 +- scripts/release/test_homebrew.py | 101 ++++++++++++++++++---- scripts/release/update-homebrew-cask.py | 27 +++--- 7 files changed, 160 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/site/download.tsx b/apps/web/src/components/site/download.tsx index 0247bdd7..67f007b7 100644 --- a/apps/web/src/components/site/download.tsx +++ b/apps/web/src/components/site/download.tsx @@ -63,3 +63,15 @@ export function Sha({ value }: { value: string }) { ); } + +export function HomebrewInstall() { + return ( +
+

Or install with Homebrew (Apple silicon):

+
+        {`brew tap opencoredev/convt https://github.com/opencoredev/convt
+brew install --cask convt`}
+      
+
+ ); +} diff --git a/apps/web/src/routes/_site/download.tsx b/apps/web/src/routes/_site/download.tsx index b415ab0e..c7b99b65 100644 --- a/apps/web/src/routes/_site/download.tsx +++ b/apps/web/src/routes/_site/download.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import { cx } from "#/components/app/ui"; -import { DownloadButton, Sha } from "#/components/site/download"; +import { DownloadButton, HomebrewInstall, Sha } from "#/components/site/download"; import { PageHeader, TextLink, siteColumn } from "#/components/site/layout"; import { isOs, @@ -192,15 +192,3 @@ function PlatformCard({ os, current }: { os: Os; current: boolean }) { ); } - -function HomebrewInstall() { - return ( -
-

Or install with Homebrew (Apple silicon):

-
-        {`brew tap opencoredev/convt https://github.com/opencoredev/convt
-brew install --cask convt`}
-      
-
- ); -} diff --git a/apps/web/test/unit/download.test.tsx b/apps/web/test/unit/download.test.tsx index 4cf8a663..b29b30d2 100644 --- a/apps/web/test/unit/download.test.tsx +++ b/apps/web/test/unit/download.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { renderToStaticMarkup } from "react-dom/server"; -import { DownloadButton } from "../../src/components/site/download"; +import { DownloadButton, HomebrewInstall } from "../../src/components/site/download"; const page = readFileSync(new URL("../../src/routes/_site/download.tsx", import.meta.url), "utf8"); @@ -39,6 +39,11 @@ test("/download has no checksum or source Coming soon sections", () => { expect(page).not.toContain("verify-title"); expect(page).toContain("DownloadButton"); expect(page).toContain("For your computer"); - expect(page).toContain("brew install --cask convt"); - expect(page).toContain("brew tap opencoredev/convt"); + expect(page).toContain("HomebrewInstall"); +}); + +test("Homebrew install block renders the tap commands", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("brew tap opencoredev/convt"); + expect(html).toContain("brew install --cask convt"); }); diff --git a/scripts/release/homebrew_cask.py b/scripts/release/homebrew_cask.py index 6c95fc07..dd067e08 100644 --- a/scripts/release/homebrew_cask.py +++ b/scripts/release/homebrew_cask.py @@ -47,17 +47,30 @@ def validate_cask(text: str) -> None: missing = [item for item in REQUIRED if item not in text] if missing: raise ValueError("cask is missing: " + ", ".join(missing)) - if VERSION_RE.search(text) is None: + cask_fields(text) + + +def cask_fields(text: str) -> tuple[str, str]: + version = VERSION_RE.search(text) + digest = SHA_RE.search(text) + if version is None: raise ValueError("cask needs a version stanza") - if SHA_RE.search(text) is None: + if digest is None: raise ValueError("cask needs a sha256 stanza") + if not VERSION_VALUE.fullmatch(version[2]): + raise ValueError(f"invalid version: {version[2]}") + if not SHA_VALUE.fullmatch(digest[2]): + raise ValueError(f"invalid sha256: {digest[2]}") + return version[2], digest[2] -def bump_cask(text: str, *, version: str, sha256: str) -> str: +def bump_cask(text: str, *, version: str, sha256: str, url: str | None = None) -> str: if not VERSION_VALUE.fullmatch(version): raise ValueError(f"invalid version: {version}") if not SHA_VALUE.fullmatch(sha256): raise ValueError(f"invalid sha256: {sha256}") + if url is not None and not url.startswith("https://"): + raise ValueError("cask url must be https") validate_cask(text) bumped, count = VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) if count != 1: @@ -65,6 +78,12 @@ def bump_cask(text: str, *, version: str, sha256: str) -> str: bumped, count = SHA_RE.subn(rf"\g<1>{sha256}\g<3>", bumped, count=1) if count != 1: raise ValueError("expected exactly one sha256 stanza") + if url is not None: + bumped, count = re.compile(r'^(\s*url\s+")([^"]+)(")', re.M).subn( + rf"\g<1>{url}\g<3>", bumped, count=1 + ) + if count != 1: + raise ValueError("expected exactly one url stanza") validate_cask(bumped) return bumped @@ -122,6 +141,13 @@ def git_env(token: str | None = None) -> dict[str, str]: return env +def git_ident_args() -> list[str]: + # Git needs user.name and user.email. Keep both local-only; never embed an address. + name = os.environ.get("GIT_AUTHOR_NAME") or "github-actions[bot]" + ident = os.environ.get("GIT_AUTHOR_EMAIL") or "github-actions[bot]" + return ["-c", f"user.name={name}", "-c", f"user.email={ident}"] + + def run_git(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> str: merged = git_env() if env: @@ -134,18 +160,7 @@ def commit_if_changed(repo: Path, path: Path, message: str) -> bool: staged = run_git(["diff", "--cached", "--name-only", "--", str(path.relative_to(repo))], cwd=repo) if not staged: return False - run_git( - [ - "-c", - "user.name=github-actions[bot]", - "-c", - "user.email=41898282+github-actions[bot]@users.noreply.github.com", - "commit", - "-m", - message, - ], - cwd=repo, - ) + run_git([*git_ident_args(), "commit", "-m", message], cwd=repo) return True @@ -156,6 +171,10 @@ def push_head(repo: Path, remote: str = "origin") -> None: subprocess.check_call(["git", "push", remote, f"HEAD:refs/heads/{branch}"], cwd=repo) +def clone_tap(url: str, dest: Path, env: dict[str, str]) -> None: + subprocess.check_call(["git", "clone", "--depth", "1", url, str(dest)], env=env) + + def publish_tap(cask: Path, *, tap: str, token: str, version: str) -> None: if "/" not in tap: raise ValueError(f"tap must be owner/repo: {tap}") @@ -163,10 +182,7 @@ def publish_tap(cask: Path, *, tap: str, token: str, version: str) -> None: env = git_env(token) try: try: - subprocess.check_call( - ["git", "clone", "--depth", "1", f"https://github.com/{tap}.git", str(work / "tap")], - env=env, - ) + clone_tap(f"https://github.com/{tap}.git", work / "tap", env) except subprocess.CalledProcessError: raise ValueError( f"could not clone {tap}; create the public repo and set HOMEBREW_TAP_TOKEN" @@ -181,19 +197,7 @@ def publish_tap(cask: Path, *, tap: str, token: str, version: str) -> None: run_git(["add", "Casks/convt.rb", "README.md"], cwd=repo, env=env) if not run_git(["diff", "--cached", "--name-only"], cwd=repo, env=env): return - run_git( - [ - "-c", - "user.name=github-actions[bot]", - "-c", - "user.email=41898282+github-actions[bot]@users.noreply.github.com", - "commit", - "-m", - f"convt {version}", - ], - cwd=repo, - env=env, - ) + run_git([*git_ident_args(), "commit", "-m", f"convt {version}"], cwd=repo, env=env) subprocess.check_call(["git", "push", "origin", "HEAD"], cwd=repo, env=env) finally: shutil.rmtree(work, ignore_errors=True) diff --git a/scripts/release/templates.py b/scripts/release/templates.py index 178aa456..8cec72e7 100644 --- a/scripts/release/templates.py +++ b/scripts/release/templates.py @@ -29,7 +29,7 @@ def copy_artifact(a,dest): homebrew_spec=spec_from_file_location('homebrew_cask',Path(__file__).with_name('homebrew_cask.py')) homebrew=module_from_spec(homebrew_spec); homebrew_spec.loader.exec_module(homebrew) source=Path(__file__).resolve().parents[2]/'Casks'/'convt.rb' - text(Path('homebrew/convt.rb'),homebrew.bump_cask(source.read_text(),version=version,sha256=mac['sha256'])) + text(Path('homebrew/convt.rb'),homebrew.bump_cask(source.read_text(),version=version,sha256=mac['sha256'],url=mac['url'])) windows=artifacts.get(('windows-x86_64','msi')) or artifacts.get(('windows-x86_64','exe')) if windows: text(Path('winget/Convt.Convt.installer.yaml'),f'''PackageIdentifier: Convt.Convt diff --git a/scripts/release/test_homebrew.py b/scripts/release/test_homebrew.py index 7c247c30..b3266abd 100644 --- a/scripts/release/test_homebrew.py +++ b/scripts/release/test_homebrew.py @@ -6,6 +6,7 @@ import sys import tempfile import unittest +from unittest.mock import patch HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] @@ -14,10 +15,15 @@ homebrew = module_from_spec(spec) spec.loader.exec_module(homebrew) +cli_spec = spec_from_file_location("update_homebrew_cask", HERE / "update-homebrew-cask.py") +cli = module_from_spec(cli_spec) +cli_spec.loader.exec_module(cli) + CASK = (REPO / "Casks" / "convt.rb").read_text() +CASK_VERSION, CASK_SHA = homebrew.cask_fields(CASK) -def manifest(version="0.2.1", sha256="b" * 64): +def manifest(version="0.2.1", sha256="b" * 64, url=None): return { "builds": [ { @@ -26,7 +32,8 @@ def manifest(version="0.2.1", sha256="b" * 64): { "platform": "macos-arm64", "kind": "dmg", - "url": f"https://github.com/opencoredev/convt/releases/download/v{version}/convt-macos-arm64.dmg", + "url": url + or f"https://github.com/opencoredev/convt/releases/download/v{version}/convt-macos-arm64.dmg", "sha256": sha256, } ], @@ -38,14 +45,16 @@ def manifest(version="0.2.1", sha256="b" * 64): class CaskFileTests(unittest.TestCase): def test_committed_cask_is_valid(self): homebrew.validate_cask(CASK) - self.assertIn('version "0.2.0"', CASK) - self.assertIn("8fc47f8b9873adbf4ad6df1db9e050d74205c26cff7e8c7e919ce0d398ae2ea3", CASK) + self.assertRegex(CASK_VERSION, r"^\d+\.\d+\.\d+$") + self.assertRegex(CASK_SHA, r"^[0-9a-f]{64}$") + self.assertIn(f'version "{CASK_VERSION}"', CASK) + self.assertIn(CASK_SHA, CASK) self.assertIn("convt-macos-arm64.dmg", CASK) self.assertIn("depends_on arch: :arm64", CASK) self.assertIn("depends_on macos: :ventura", CASK) self.assertNotIn("x86_64", CASK) self.assertNotIn("intel", CASK) - self.assertNotIn('arch arm:', CASK) + self.assertNotIn("arch arm:", CASK) self.assertIn("~/Library/Application Support/convt", CASK) for line in CASK.splitlines(): if line and not line.startswith("cask ") and line != "end": @@ -64,14 +73,21 @@ def test_check_cli(self): class BumpTests(unittest.TestCase): def test_replaces_version_and_sha_only(self): - bumped = homebrew.bump_cask(CASK, version="0.2.1", sha256="c" * 64) - self.assertIn('version "0.2.1"', bumped) + bumped = homebrew.bump_cask(CASK, version="9.9.9", sha256="c" * 64) + self.assertIn('version "9.9.9"', bumped) self.assertIn('sha256 "' + "c" * 64 + '"', bumped) - self.assertNotIn('version "0.2.0"', bumped) + self.assertNotIn(f'version "{CASK_VERSION}"', bumped) + self.assertNotIn(CASK_SHA, bumped) self.assertIn("livecheck do", bumped) self.assertIn("zap trash:", bumped) self.assertEqual(bumped.count("cask "), 1) + def test_url_override_keeps_the_manifest_host(self): + url = "https://downloads.convt.app/9.9.9/convt-macos-arm64.dmg" + bumped = homebrew.bump_cask(CASK, version="9.9.9", sha256="c" * 64, url=url) + self.assertIn(url, bumped) + self.assertNotIn("github.com/opencoredev/convt/releases/download", bumped) + def test_rejects_bad_values(self): with self.assertRaisesRegex(ValueError, "invalid version"): homebrew.bump_cask(CASK, version="v0.2.1", sha256="c" * 64) @@ -120,16 +136,67 @@ def test_http_manifest_url_is_refused(self): def test_missing_tap_error_omits_the_token(self): token = "SECRETTOKENVALUE" - with self.assertRaises(ValueError) as raised: - homebrew.publish_tap( - REPO / "Casks" / "convt.rb", - tap="opencoredev/this-tap-does-not-exist-3756", - token=token, - version="0.2.0", - ) + + def fail_clone(url, dest, env): + self.assertIn("this-tap-does-not-exist-3756", url) + self.assertNotIn(token, url) + raise subprocess.CalledProcessError(128, ["git", "clone"]) + + with patch.object(homebrew, "clone_tap", side_effect=fail_clone): + with self.assertRaises(ValueError) as raised: + homebrew.publish_tap( + REPO / "Casks" / "convt.rb", + tap="opencoredev/this-tap-does-not-exist-3756", + token=token, + version="0.2.0", + ) self.assertIn("HOMEBREW_TAP_TOKEN", str(raised.exception)) self.assertNotIn(token, str(raised.exception)) + def test_push_retries_when_the_cask_already_matches(self): + with tempfile.TemporaryDirectory(prefix="convt-cask-retry-") as tmp: + dest = Path(tmp) / "Casks" / "convt.rb" + dest.parent.mkdir() + dest.write_text(homebrew.bump_cask(CASK, version="0.3.0", sha256="d" * 64)) + payload = Path(tmp) / "release-manifest.json" + payload.write_text(json.dumps(manifest("0.3.0", "d" * 64))) + with ( + patch.object(cli.homebrew, "commit_if_changed", return_value=False) as commit, + patch.object(cli.homebrew, "push_head") as push, + ): + rc = cli.main( + [ + "--cask", + str(dest), + "--manifest", + str(payload), + "--commit", + "--push", + "--skip-tap", + ] + ) + self.assertEqual(rc, 0) + commit.assert_called_once() + push.assert_called_once() + + def test_commit_failure_still_publishes_the_tap(self): + with tempfile.TemporaryDirectory(prefix="convt-cask-tap-") as tmp: + dest = Path(tmp) / "Casks" / "convt.rb" + dest.parent.mkdir() + dest.write_text(CASK) + payload = Path(tmp) / "release-manifest.json" + payload.write_text(json.dumps(manifest("0.3.0", "d" * 64))) + with ( + patch.object(cli.homebrew, "commit_if_changed", side_effect=RuntimeError("hook")), + patch.dict("os.environ", {"HOMEBREW_TAP_TOKEN": "tap-token"}, clear=False), + patch.object(cli.homebrew, "publish_tap") as publish, + ): + rc = cli.main( + ["--cask", str(dest), "--manifest", str(payload), "--commit", "--tap", "opencoredev/homebrew-tap"] + ) + self.assertEqual(rc, 1) + publish.assert_called_once() + def test_release_workflow_cannot_block_publish(self): text = (REPO / ".github" / "workflows" / "release.yml").read_text() self.assertIn("continue-on-error: true", text) @@ -144,6 +211,7 @@ def test_templates_emit_the_bumped_cask(self): root = Path(tmp) (root / "convt-macos-arm64.dmg").write_bytes(b"dmg") digest = __import__("hashlib").sha256(b"dmg").hexdigest() + url = "https://downloads.convt.app/0.4.0/convt-macos-arm64.dmg" payload = { "distribution_ready": True, "builds": [ @@ -153,7 +221,7 @@ def test_templates_emit_the_bumped_cask(self): { "platform": "macos-arm64", "kind": "dmg", - "url": "https://github.com/opencoredev/convt/releases/download/v0.4.0/convt-macos-arm64.dmg", + "url": url, "size": 3, "sha256": digest, } @@ -174,6 +242,7 @@ def test_templates_emit_the_bumped_cask(self): rendered = (out / "homebrew" / "convt.rb").read_text() self.assertIn('version "0.4.0"', rendered) self.assertIn(digest, rendered) + self.assertIn(url, rendered) self.assertIn("livecheck do", rendered) self.assertIn("zap trash:", rendered) status = json.loads((out / "STATUS.json").read_text()) diff --git a/scripts/release/update-homebrew-cask.py b/scripts/release/update-homebrew-cask.py index ea1110a0..e3df030a 100755 --- a/scripts/release/update-homebrew-cask.py +++ b/scripts/release/update-homebrew-cask.py @@ -63,16 +63,23 @@ def main(argv: list[str] | None = None) -> int: print(f"{'updated' if changed else 'unchanged'} {cask} {version} {sha256}") errors: list[str] = [] repo = homebrew.REPO if cask.is_relative_to(homebrew.REPO) else cask.parent.parent - if changed and args.commit: - message = f"chore(homebrew): bump convt to {version}" - if homebrew.commit_if_changed(repo, cask, message): - print(f"committed {cask.relative_to(repo)}") - if args.push: - try: - homebrew.push_head(repo) - print("pushed this repository") - except Exception as error: # noqa: BLE001 — report, then still try the tap - errors.append(f"push this repository: {error}") + # Commit, push and tap are independent. A failed push must still be + # retryable when the cask file already matches, and a failed commit + # must not skip the tap. + if args.commit: + try: + if homebrew.commit_if_changed(repo, cask, f"chore(homebrew): bump convt to {version}"): + print(f"committed {cask.relative_to(repo)}") + else: + print("nothing to commit") + except Exception as error: # noqa: BLE001 — still try push and tap + errors.append(f"commit this repository: {error}") + if args.push: + try: + homebrew.push_head(repo) + print("pushed this repository") + except Exception as error: # noqa: BLE001 — still try the tap + errors.append(f"push this repository: {error}") token = os.environ.get("HOMEBREW_TAP_TOKEN", "").strip() if not args.skip_tap and token: try: From 8a46adf42a6dcdf73b71d7cfc18331232f1a9940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 23:49:00 +0000 Subject: [PATCH 3/3] Keep Homebrew cask updates from writing stale releases Pass the manifest DMG URL through to bump_cask so a retry cannot leave the cask pointing at an old host. Refuse a version older than the checked-in cask, and commit only the cask file so leftover staged paths stay out of the source-repo commit. Co-authored-by: Leo --- scripts/release/homebrew_cask.py | 22 ++++++-- scripts/release/test_homebrew.py | 71 ++++++++++++++++++++++++- scripts/release/update-homebrew-cask.py | 8 +-- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/scripts/release/homebrew_cask.py b/scripts/release/homebrew_cask.py index dd067e08..db79d8b3 100644 --- a/scripts/release/homebrew_cask.py +++ b/scripts/release/homebrew_cask.py @@ -64,6 +64,11 @@ def cask_fields(text: str) -> tuple[str, str]: return version[2], digest[2] +def version_key(version: str) -> tuple[int, int, int]: + major, minor, patch = (int(part) for part in version.split(".")) + return major, minor, patch + + def bump_cask(text: str, *, version: str, sha256: str, url: str | None = None) -> str: if not VERSION_VALUE.fullmatch(version): raise ValueError(f"invalid version: {version}") @@ -72,6 +77,9 @@ def bump_cask(text: str, *, version: str, sha256: str, url: str | None = None) - if url is not None and not url.startswith("https://"): raise ValueError("cask url must be https") validate_cask(text) + current, _ = cask_fields(text) + if version_key(version) < version_key(current): + raise ValueError(f"refusing to downgrade cask from {current} to {version}") bumped, count = VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) if count != 1: raise ValueError("expected exactly one version stanza") @@ -88,7 +96,7 @@ def bump_cask(text: str, *, version: str, sha256: str, url: str | None = None) - return bumped -def dmg_from_manifest(manifest: dict) -> tuple[str, str]: +def dmg_from_manifest(manifest: dict) -> tuple[str, str, str]: builds = manifest.get("builds") or [] if not builds: raise ValueError("manifest has no builds") @@ -99,9 +107,12 @@ def dmg_from_manifest(manifest: dict) -> tuple[str, str]: for artifact in build.get("artifacts") or []: if artifact.get("platform") == "macos-arm64" and artifact.get("kind") == "dmg": digest = artifact.get("sha256") + url = artifact.get("url") if not isinstance(digest, str) or not SHA_VALUE.fullmatch(digest): raise ValueError("macos-arm64 dmg has a bad sha256") - return version, digest + if not isinstance(url, str) or not url.startswith("https://"): + raise ValueError("macos-arm64 dmg has a bad url") + return version, digest, url raise ValueError("manifest has no macos-arm64 dmg") @@ -156,11 +167,12 @@ def run_git(args: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> def commit_if_changed(repo: Path, path: Path, message: str) -> bool: - run_git(["add", "--", str(path.relative_to(repo))], cwd=repo) - staged = run_git(["diff", "--cached", "--name-only", "--", str(path.relative_to(repo))], cwd=repo) + rel = str(path.relative_to(repo)) + run_git(["add", "--", rel], cwd=repo) + staged = run_git(["diff", "--cached", "--name-only", "--", rel], cwd=repo) if not staged: return False - run_git([*git_ident_args(), "commit", "-m", message], cwd=repo) + run_git([*git_ident_args(), "commit", "-m", message, "--", rel], cwd=repo) return True diff --git a/scripts/release/test_homebrew.py b/scripts/release/test_homebrew.py index b3266abd..cccb7cbf 100644 --- a/scripts/release/test_homebrew.py +++ b/scripts/release/test_homebrew.py @@ -94,20 +94,33 @@ def test_rejects_bad_values(self): with self.assertRaisesRegex(ValueError, "invalid sha256"): homebrew.bump_cask(CASK, version="0.2.1", sha256="C" * 64) + def test_refuses_an_older_version(self): + newer = homebrew.bump_cask(CASK, version="0.3.0", sha256="d" * 64) + with self.assertRaisesRegex(ValueError, r"downgrade cask from 0\.3\.0 to 0\.2\.1"): + homebrew.bump_cask(newer, version="0.2.1", sha256="e" * 64) + same = homebrew.bump_cask(newer, version="0.3.0", sha256="f" * 64) + self.assertIn('sha256 "' + "f" * 64 + '"', same) + self.assertGreater(homebrew.version_key("0.2.10"), homebrew.version_key("0.2.9")) + def test_manifest_reads_latest_mac_dmg(self): - version, digest = homebrew.dmg_from_manifest(manifest()) + url = "https://downloads.convt.app/0.2.1/convt-macos-arm64.dmg" + version, digest, got = homebrew.dmg_from_manifest(manifest(url=url)) self.assertEqual(version, "0.2.1") self.assertEqual(digest, "b" * 64) + self.assertEqual(got, url) with self.assertRaisesRegex(ValueError, "macos-arm64 dmg"): homebrew.dmg_from_manifest({"builds": [{"version": "1.0.0", "artifacts": []}]}) + with self.assertRaisesRegex(ValueError, "bad url"): + homebrew.dmg_from_manifest(manifest(url="http://example.test/convt-macos-arm64.dmg")) def test_cli_rewrites_a_copy(self): + url = "https://downloads.convt.app/0.3.0/convt-macos-arm64.dmg" with tempfile.TemporaryDirectory(prefix="convt-cask-") as tmp: dest = Path(tmp) / "Casks" / "convt.rb" dest.parent.mkdir() dest.write_text(CASK) payload = Path(tmp) / "release-manifest.json" - payload.write_text(json.dumps(manifest("0.3.0", "d" * 64))) + payload.write_text(json.dumps(manifest("0.3.0", "d" * 64, url=url))) result = subprocess.run( [ sys.executable, @@ -126,8 +139,36 @@ def test_cli_rewrites_a_copy(self): text = dest.read_text() self.assertIn('version "0.3.0"', text) self.assertIn('sha256 "' + "d" * 64 + '"', text) + self.assertIn(url, text) + self.assertNotIn("github.com/opencoredev/convt/releases/download", text) self.assertIn("updated ", result.stdout) + def test_cli_refuses_an_older_release(self): + with tempfile.TemporaryDirectory(prefix="convt-cask-old-") as tmp: + dest = Path(tmp) / "Casks" / "convt.rb" + dest.parent.mkdir() + dest.write_text(homebrew.bump_cask(CASK, version="0.3.0", sha256="d" * 64)) + original = dest.read_text() + payload = Path(tmp) / "release-manifest.json" + payload.write_text(json.dumps(manifest("0.2.9", "e" * 64))) + result = subprocess.run( + [ + sys.executable, + str(HERE / "update-homebrew-cask.py"), + "--cask", + str(dest), + "--manifest", + str(payload), + "--skip-tap", + ], + check=False, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("downgrade", result.stderr) + self.assertEqual(dest.read_text(), original) + class GuardTests(unittest.TestCase): def test_http_manifest_url_is_refused(self): @@ -179,6 +220,32 @@ def test_push_retries_when_the_cask_already_matches(self): commit.assert_called_once() push.assert_called_once() + def test_commit_only_the_cask_file(self): + with tempfile.TemporaryDirectory(prefix="convt-cask-commit-") as tmp: + repo = Path(tmp) + dest = repo / "Casks" / "convt.rb" + dest.parent.mkdir() + dest.write_text(CASK) + extra = repo / "NOTES" + extra.write_text("already staged leftover") + homebrew.run_git(["init", "-b", "main"], cwd=repo) + homebrew.run_git(["add", "--", "Casks/convt.rb"], cwd=repo) + homebrew.run_git( + [*homebrew.git_ident_args(), "commit", "-m", "seed", "--", "Casks/convt.rb"], + cwd=repo, + ) + dest.write_text(homebrew.bump_cask(CASK, version="0.3.0", sha256="d" * 64)) + homebrew.run_git(["add", "--", "NOTES"], cwd=repo) + self.assertTrue(homebrew.commit_if_changed(repo, dest, "chore(homebrew): bump convt to 0.3.0")) + committed = homebrew.run_git( + ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], + cwd=repo, + ) + self.assertEqual(committed, "Casks/convt.rb") + still_staged = homebrew.run_git(["diff", "--cached", "--name-only"], cwd=repo) + self.assertEqual(still_staged, "NOTES") + self.assertIn("already staged leftover", extra.read_text()) + def test_commit_failure_still_publishes_the_tap(self): with tempfile.TemporaryDirectory(prefix="convt-cask-tap-") as tmp: dest = Path(tmp) / "Casks" / "convt.rb" diff --git a/scripts/release/update-homebrew-cask.py b/scripts/release/update-homebrew-cask.py index e3df030a..99a2f802 100755 --- a/scripts/release/update-homebrew-cask.py +++ b/scripts/release/update-homebrew-cask.py @@ -38,13 +38,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) -def version_and_sha(args: argparse.Namespace) -> tuple[str, str]: +def release_fields(args: argparse.Namespace) -> tuple[str, str, str | None]: if args.manifest is not None: return homebrew.dmg_from_manifest(json.loads(args.manifest.read_text())) if args.from_manifest_url: return homebrew.dmg_from_manifest(homebrew.fetch_json(args.from_manifest_url)) if args.version and args.sha256: - return args.version, args.sha256 + return args.version, args.sha256, None raise ValueError("pass --version and --sha256, --manifest, or --from-manifest-url") @@ -57,8 +57,8 @@ def main(argv: list[str] | None = None) -> int: if args.check: print(f"ok {cask}") return 0 - version, sha256 = version_and_sha(args) - bumped = homebrew.bump_cask(text, version=version, sha256=sha256) + version, sha256, url = release_fields(args) + bumped = homebrew.bump_cask(text, version=version, sha256=sha256, url=url) changed = homebrew.write_cask(cask, bumped) print(f"{'updated' if changed else 'unchanged'} {cask} {version} {sha256}") errors: list[str] = []