Files
homebrew/scripts/update_librewolf.py
2026-08-10 13:36:44 +02:00

168 lines
6.1 KiB
Python
Executable File

#!/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<major>\d+)\.(?P<minor>\d+)(?:\.(?P<patch>\d+))?-(?P<build>\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())