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 f822f24e..f0fcef12 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,14 @@ Local file conversion for your own machine. Right-click a file, pick a format, a
## Install
-Download the app from [convt.app/download](https://convt.app/download). Release 0.2.0 has a disk image for macOS on Apple silicon and an MSI installer for Windows x86_64. The Linux packages (AppImage, .deb, .rpm and a tarball) build from `packaging/linux` but are not in a release yet. 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`. Download the app from [convt.app/download](https://convt.app/download) for a disk image, Windows MSI, or Linux packages. There is no Linux Homebrew formula yet; use the `.deb`, `.rpm` or AppImage. Packaged builds start a 7-day trial on the first conversion.
To build from source:
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 3e0e1252..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,
@@ -125,6 +125,7 @@ function Recommended({ os }: { os: Os }) {
{slot?.artifact && }
+ {os === "macos" && }
Not your system?{" "}
@@ -187,6 +188,7 @@ function PlatformCard({ os, current }: { os: Os; current: boolean }) {
))}
+ {os === "macos" && }
);
}
diff --git a/apps/web/test/unit/download.test.tsx b/apps/web/test/unit/download.test.tsx
index 6fedd997..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,4 +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("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/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..db79d8b3
--- /dev/null
+++ b/scripts/release/homebrew_cask.py
@@ -0,0 +1,215 @@
+#!/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))
+ 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 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 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}")
+ 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)
+ 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")
+ 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
+
+
+def dmg_from_manifest(manifest: dict) -> tuple[str, 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")
+ url = artifact.get("url")
+ if not isinstance(digest, str) or not SHA_VALUE.fullmatch(digest):
+ raise ValueError("macos-arm64 dmg has a bad sha256")
+ 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")
+
+
+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 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:
+ 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:
+ 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, "--", rel], 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 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}")
+ work = Path(tempfile.mkdtemp(prefix="convt-homebrew-tap-"))
+ env = git_env(token)
+ try:
+ try:
+ 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"
+ ) 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([*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 42d400ca..8cec72e7 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'],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
new file mode 100644
index 00000000..cccb7cbf
--- /dev/null
+++ b/scripts/release/test_homebrew.py
@@ -0,0 +1,320 @@
+"""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
+from unittest.mock import patch
+
+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)
+
+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, url=None):
+ return {
+ "builds": [
+ {
+ "version": version,
+ "artifacts": [
+ {
+ "platform": "macos-arm64",
+ "kind": "dmg",
+ "url": url
+ or 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.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.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="9.9.9", sha256="c" * 64)
+ self.assertIn('version "9.9.9"', bumped)
+ self.assertIn('sha256 "' + "c" * 64 + '"', 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)
+ 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):
+ 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, url=url)))
+ 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(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):
+ 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"
+
+ 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_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"
+ 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)
+ 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()
+ url = "https://downloads.convt.app/0.4.0/convt-macos-arm64.dmg"
+ payload = {
+ "distribution_ready": True,
+ "builds": [
+ {
+ "version": "0.4.0",
+ "artifacts": [
+ {
+ "platform": "macos-arm64",
+ "kind": "dmg",
+ "url": url,
+ "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(url, 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..99a2f802
--- /dev/null
+++ b/scripts/release/update-homebrew-cask.py
@@ -0,0 +1,102 @@
+#!/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 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, None
+ 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, 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] = []
+ repo = homebrew.REPO if cask.is_relative_to(homebrew.REPO) else cask.parent.parent
+ # 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:
+ 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())