This commit is contained in:
2026-08-10 13:36:44 +02:00
commit c3865d8c72
6 changed files with 359 additions and 0 deletions
+31
View File
@@ -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
+47
View File
@@ -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
+23
View File
@@ -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.
+167
View File
@@ -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<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())
View File
+91
View File
@@ -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()