From c3865d8c72569e03b443fea5d78fc03f6015f30f Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Mon, 10 Aug 2026 13:36:44 +0200 Subject: [PATCH] initial --- .gitea/workflows/update-librewolf.yml | 31 +++++ Casks/librewolf.rb | 47 ++++++++ LICENSE | 23 ++++ scripts/update_librewolf.py | 167 ++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_update_librewolf.py | 91 ++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 .gitea/workflows/update-librewolf.yml create mode 100644 Casks/librewolf.rb create mode 100644 LICENSE create mode 100755 scripts/update_librewolf.py create mode 100644 tests/__init__.py create mode 100644 tests/test_update_librewolf.py diff --git a/.gitea/workflows/update-librewolf.yml b/.gitea/workflows/update-librewolf.yml new file mode 100644 index 0000000..38b7994 --- /dev/null +++ b/.gitea/workflows/update-librewolf.yml @@ -0,0 +1,31 @@ +name: Update LibreWolf cask + +on: + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Check out tap + uses: actions/checkout@v4 + + - name: Update from latest stable release + run: python3 scripts/update_librewolf.py + + - name: Commit update + run: | + if git diff --quiet -- Casks/librewolf.rb; then + echo "LibreWolf is already up to date" + exit 0 + fi + git config user.name "librewolf-update-bot" + git config user.email "librewolf-update-bot@users.noreply.gitea.com" + git add Casks/librewolf.rb + git commit -m "Update LibreWolf cask" + git push diff --git a/Casks/librewolf.rb b/Casks/librewolf.rb new file mode 100644 index 0000000..74ec577 --- /dev/null +++ b/Casks/librewolf.rb @@ -0,0 +1,47 @@ +cask "librewolf" do + arch arm: "arm64", intel: "x86_64" + os macos: "macos-#{arch}-package.dmg", linux: "linux-#{arch}-appimage.AppImage" + + version "153.0.3,1" + sha256 arm: "cf28a2c610cd8be0f698b52c1fb6e530d17be09fb43aba798e762d8423b35950", + intel: "031050b89658c380d91f2658bfae7ff7f1297be4fabc240f45f15ef506d2aa75", + arm64_linux: "5a20e87564950dc6edcceba9969206664d4df24f7811c4ef545b6cd217fa0807", + x86_64_linux: "85da8f1bca931c4f1a2cea9140dd846da9cf2868afe085f556b0e5d637e8467d" + + on_macos do + disable! date: "2026-09-01", because: :fails_gatekeeper_check + + app "LibreWolf.app" + command_wrapper "librewolf", + executable: "#{appdir}/LibreWolf.app/Contents/MacOS/librewolf" + + zap trash: [ + "~/.librewolf", + "~/Library/Application Support/LibreWolf", + "~/Library/Caches/LibreWolf Community", + "~/Library/Caches/LibreWolf", + "~/Library/Preferences/io.gitlab.librewolf-community.librewolf.plist", + "~/Library/Saved Application State/io.gitlab.librewolf-community.librewolf.savedState", + ] + end + on_linux do + app_image "librewolf-#{version.tr(",", "-")}-linux-#{arch}-appimage.AppImage", + target: "LibreWolf.AppImage" + end + + url "https://codeberg.org/api/packages/librewolf/generic/librewolf/#{version.tr(",", "-")}/librewolf-#{version.tr(",", "-")}-#{os}" + name "LibreWolf" + desc "Web browser" + homepage "https://librewolf.net/" + + # There can be a notable gap between when a version is tagged and a + # corresponding release is created, so we check the "latest" release instead + # of the Git tags. + livecheck do + url "https://codeberg.org/api/v1/repos/librewolf/bsys6/releases/latest" + regex(/^v?(\d+(?:[.-]\d+)+)$/i) + strategy :json do |json, regex| + json["tag_name"]&.[](regex, 1)&.tr("-", ",") + end + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..54c7c1c --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright © 2013, Paul Hinze & Contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/scripts/update_librewolf.py b/scripts/update_librewolf.py new file mode 100755 index 0000000..458aafa --- /dev/null +++ b/scripts/update_librewolf.py @@ -0,0 +1,167 @@ +#!/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()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_update_librewolf.py b/tests/test_update_librewolf.py new file mode 100644 index 0000000..10cd71a --- /dev/null +++ b/tests/test_update_librewolf.py @@ -0,0 +1,91 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts import update_librewolf + + +class VersionOrderingTest(unittest.TestCase): + def test_patch_release_is_newer_than_rebuild_without_patch(self) -> None: + self.assertLess( + update_librewolf.version_key("153.0-3"), + update_librewolf.version_key("153.0.1-1"), + ) + + def test_cask_and_release_versions_have_same_ordering(self) -> None: + self.assertEqual( + update_librewolf.version_key("153.0.3,1"), + update_librewolf.version_key("153.0.3-1"), + ) + + +class StableReleaseTest(unittest.TestCase): + @patch.object(update_librewolf, "fetch") + def test_prerelease_is_rejected(self, fetch) -> None: + fetch.return_value = json.dumps( + { + "tag_name": "154.0-1", + "draft": False, + "prerelease": True, + } + ).encode() + + with self.assertRaisesRegex(ValueError, "not stable"): + update_librewolf.latest_stable_release() + + +class CaskUpdateTest(unittest.TestCase): + CASK = '''cask "librewolf" do + version "152.0.6,1" + sha256 arm: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + intel: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + arm64_linux: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + x86_64_linux: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +end +''' + CHECKSUMS = { + "arm": "1" * 64, + "intel": "2" * 64, + "arm64_linux": "3" * 64, + "x86_64_linux": "4" * 64, + } + + @patch.object(update_librewolf, "release_checksums") + @patch.object(update_librewolf, "latest_stable_release") + def test_new_stable_release_updates_version_and_all_hashes( + self, latest_stable_release, release_checksums + ) -> None: + latest_stable_release.return_value = {"stable_tag": "153.0.3-1"} + release_checksums.return_value = self.CHECKSUMS + + with tempfile.TemporaryDirectory() as directory: + cask = Path(directory) / "librewolf.rb" + cask.write_text(self.CASK) + + self.assertTrue(update_librewolf.update_cask(cask)) + updated = cask.read_text() + + self.assertIn('version "153.0.3,1"', updated) + for checksum in self.CHECKSUMS.values(): + self.assertIn(checksum, updated) + + @patch.object(update_librewolf, "release_checksums") + @patch.object(update_librewolf, "latest_stable_release") + def test_older_release_does_not_downgrade_cask( + self, latest_stable_release, release_checksums + ) -> None: + latest_stable_release.return_value = {"stable_tag": "151.0.3-1"} + + with tempfile.TemporaryDirectory() as directory: + cask = Path(directory) / "librewolf.rb" + cask.write_text(self.CASK) + self.assertFalse(update_librewolf.update_cask(cask)) + self.assertEqual(cask.read_text(), self.CASK) + + release_checksums.assert_not_called() + + +if __name__ == "__main__": + unittest.main()