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())