#!/usr/bin/env python3 """Update the LibreWolf cask from the latest stable bsys6 release.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any from urllib.request import Request, urlopen ROOT = Path(__file__).resolve().parents[1] DEFAULT_CASK = ROOT / "Casks" / "librewolf.rb" # Forgejo API for https://librewolf.dev/librewolf/bsys6/releases. LATEST_RELEASE_API = "https://librewolf.dev/api/v1/repos/librewolf/bsys6/releases/latest" PACKAGE_BASE_URL = "https://codeberg.org/api/packages/librewolf/generic/librewolf" USER_AGENT = "homebrew-librewolf-release-updater/1.0" VERSION_RE = re.compile(r'^ version "([^"]+)"$', re.MULTILINE) SHA256_RE = re.compile( r'^ sha256 arm:\s+"[0-9a-f]{64}",\n' r'\s+intel:\s+"[0-9a-f]{64}",\n' r'\s+arm64_linux:\s+"[0-9a-f]{64}",\n' r'\s+x86_64_linux:\s+"[0-9a-f]{64}"$', re.MULTILINE, ) STABLE_TAG_RE = re.compile( r"^(?P\d+)\.(?P\d+)(?:\.(?P\d+))?-(?P\d+)$" ) CHECKSUM_RE = re.compile(r"^[0-9a-fA-F]{64}$") ASSET_SUFFIXES = { "arm": "macos-arm64-package.dmg", "intel": "macos-x86_64-package.dmg", "arm64_linux": "linux-arm64-appimage.AppImage", "x86_64_linux": "linux-x86_64-appimage.AppImage", } def fetch(url: str, limit: int) -> bytes: request = Request( url, headers={"Accept": "application/json", "User-Agent": USER_AGENT}, ) with urlopen(request, timeout=30) as response: body = response.read(limit + 1) if len(body) > limit: raise ValueError(f"response from {url} exceeds {limit} bytes") return body def latest_stable_release() -> dict[str, Any]: release = json.loads(fetch(LATEST_RELEASE_API, 2_000_000)) if not isinstance(release, dict): raise ValueError("latest release API did not return an object") if release.get("draft") or release.get("prerelease"): raise ValueError("latest release is not stable") raw_tag = release.get("tag_name") if not isinstance(raw_tag, str): raise ValueError("latest release has no tag_name") tag = raw_tag.removeprefix("v") if not STABLE_TAG_RE.fullmatch(tag): raise ValueError(f"latest release tag is not a stable version: {raw_tag!r}") release["stable_tag"] = tag return release def version_key(version: str) -> tuple[int, int, int, int]: normalized = version.removeprefix("v").replace(",", "-") match = STABLE_TAG_RE.fullmatch(normalized) if match is None: raise ValueError(f"invalid cask version: {version!r}") return ( int(match["major"]), int(match["minor"]), int(match["patch"] or 0), int(match["build"]), ) def release_checksums(release: dict[str, Any]) -> dict[str, str]: assets = release.get("assets") if not isinstance(assets, list): raise ValueError("latest release has no asset list") by_name: dict[str, dict[str, Any]] = {} for asset in assets: if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): continue name = asset["name"] if name in by_name: raise ValueError(f"duplicate release asset: {name}") by_name[name] = asset tag = release["stable_tag"] checksums: dict[str, str] = {} for cask_arch, suffix in ASSET_SUFFIXES.items(): package_name = f"librewolf-{tag}-{suffix}" if package_name not in by_name: raise ValueError(f"missing release asset: {package_name}") checksum_name = f"{package_name}.sha256sum" if checksum_name not in by_name: raise ValueError(f"missing release asset: {checksum_name}") checksum_url = f"{PACKAGE_BASE_URL}/{tag}/{checksum_name}" checksum_text = fetch(checksum_url, 4096).decode("ascii").strip() checksum = checksum_text.split(maxsplit=1)[0] if checksum_text else "" if not CHECKSUM_RE.fullmatch(checksum): raise ValueError(f"invalid checksum for {package_name}") checksums[cask_arch] = checksum.lower() return checksums def update_cask(cask_path: Path) -> bool: source = cask_path.read_text(encoding="utf-8") version_match = VERSION_RE.search(source) if version_match is None: raise ValueError(f"could not find version stanza in {cask_path}") current_version = version_match.group(1) release = latest_stable_release() latest_tag = release["stable_tag"] latest_key = version_key(latest_tag) current_key = version_key(current_version) comparison = (latest_key > current_key) - (latest_key < current_key) if comparison <= 0: state = "current" if comparison == 0 else "newer than the latest stable release" print(f"LibreWolf {current_version} is {state}; no update needed") return False checksums = release_checksums(release) cask_version = latest_tag.replace("-", ",") updated, version_count = VERSION_RE.subn(f' version "{cask_version}"', source, count=1) sha256_stanza = ( f' sha256 arm: "{checksums["arm"]}",\n' f' intel: "{checksums["intel"]}",\n' f' arm64_linux: "{checksums["arm64_linux"]}",\n' f' x86_64_linux: "{checksums["x86_64_linux"]}"' ) updated, sha256_count = SHA256_RE.subn(sha256_stanza, updated, count=1) if version_count != 1 or sha256_count != 1: raise ValueError(f"could not update version and sha256 stanzas in {cask_path}") temporary_path = cask_path.with_suffix(f"{cask_path.suffix}.tmp") temporary_path.write_text(updated, encoding="utf-8") temporary_path.replace(cask_path) print(f"Updated LibreWolf from {current_version} to {cask_version}") return True def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cask", type=Path, default=DEFAULT_CASK) args = parser.parse_args() try: update_cask(args.cask) except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as error: print(f"error: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())