diff --git a/README.md b/README.md index b71d277..0fcaae0 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,26 @@ Then use packages from the flake output for your system: pkgs, ... }: let - nurPkgs = inputs.adam0-nur.packages.${pkgs.stdenv.hostPlatform.system}; + nurPkgs = inputs.adam0-nur.legacyPackages.${pkgs.stdenv.hostPlatform.system}; in { home.packages = [ nurPkgs.gruvbox-plus-icons - nurPkgs.bibata-modern-cursors-gruvbox-dark + nurPkgs.bibata.modern.cursors.gruvbox-dark + nurPkgs.bibata.modern.cursors.gruvbox-dark.hyprcursor ]; } ``` +Bibata exposes 40 XCursor themes and their 40 Hyprcursor variants: + +```nix +bibata..cursors. +bibata..cursors..hyprcursor +``` + +Available palettes are `classic`, `gruvbox-dark`, `gruvbox-light`, `rosepine`, +`rosepine-dawn`, `rosepine-moon`, and the four `catppuccin-*` flavors. + For classic NUR usage, import it like any other NUR repository: ```nix diff --git a/ci.nix b/ci.nix index df371ac..1a3329e 100644 --- a/ci.nix +++ b/ci.nix @@ -48,7 +48,7 @@ if shouldRecurseForDerivations p then flattenPkgs p else if isDerivation p - then [p] + then [p] ++ (p.ciPackages or []) else []; in concatMap f (attrValues s); diff --git a/default.nix b/default.nix index 1eb69c2..81e15bf 100644 --- a/default.nix +++ b/default.nix @@ -56,6 +56,7 @@ in # keep-sorted end # keep-sorted start + bibata = recurseCallPackage ./pkgs/bibata; ghosttyShaders = recurseCallPackage ./pkgs/ghostty-shaders; gotifyPlugins = recurseIntoAttrs (callPackage ./pkgs/gotify-server/plugins {inherit (allPackages) gotify-server;}); hyprlandPlugins = recurseCallPackage ./pkgs/hyprland/plugins; diff --git a/pkgs/bibata-modern-cursors-gruvbox-dark-hyprcursor/default.nix b/pkgs/bibata-modern-cursors-gruvbox-dark-hyprcursor/default.nix deleted file mode 100644 index 850d3ad..0000000 --- a/pkgs/bibata-modern-cursors-gruvbox-dark-hyprcursor/default.nix +++ /dev/null @@ -1,81 +0,0 @@ -{ - # keep-sorted start - bash, - clickgen, - fetchFromGitHub, - fetchzip, - hyprcursor, - lib, - python3, - stdenvNoCC, - xcur2png, - # keep-sorted end -}: -stdenvNoCC.mkDerivation rec { - pname = "bibata-cursors-gruvbox-dark-hyprcursor"; - version = "1.1.0"; - - src = fetchFromGitHub { - owner = "adam01110"; - repo = "bibata-cursor"; - rev = "${version}"; - hash = "sha256-73DrcBnJQCmqgBs/Och034+AyexGeCULw0BDTJwMRjQ="; - }; - - bitmaps = fetchzip { - url = "https://github.com/adam01110/bibata-cursor/releases/download/${version}/Bibata-Modern-Gruvbox-Dark.zip"; - hash = "sha256-MHR5mhZJOXJsUvcJU41ZRe1dFFEcay93NbYllD4i4GM="; - }; - - nativeBuildInputs = [ - # keep-sorted start - bash - clickgen - hyprcursor - python3 - xcur2png - # keep-sorted end - ]; - - buildPhase = '' - runHook preBuild - - # Build xcursors - ctgen build.toml -d $bitmaps -n 'Bibata-Modern-Gruvbox-Dark' -c 'Gruvbox dark Bibata modern XCursors' - - # Build hyprcursors - buildScript=hyprcursor-build.sh - if [[ -f scripts/build-hyprcursor.sh ]]; then - buildScript=scripts/build-hyprcursor.sh - fi - bash "$buildScript" - - # Ensure manifest names match the hyprcursor pack directories - for manifest in bin/*-hyprcursor/manifest.hl; do - if [ -f "$manifest" ]; then - pack_name=$(basename "$(dirname "$manifest")") - sed -i "s/^name = .*/name = ''${pack_name}/" "$manifest" - fi - done - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - install -dm 0755 $out/share/icons - cp -rf bin/*-hyprcursor $out/share/icons/ - - runHook postInstall - ''; - - meta = with lib; { - # keep-sorted start - description = "Bibata modern cursor (Gruvbox dark & left variant)"; - homepage = "https://github.com/adam01110/bibata-cursor"; - license = licenses.gpl3Only; - platforms = platforms.linux; - # keep-sorted end - }; -} diff --git a/pkgs/bibata-modern-cursors-gruvbox-dark/default.nix b/pkgs/bibata-modern-cursors-gruvbox-dark/default.nix deleted file mode 100644 index 4839493..0000000 --- a/pkgs/bibata-modern-cursors-gruvbox-dark/default.nix +++ /dev/null @@ -1,54 +0,0 @@ -{ - # keep-sorted start - clickgen, - fetchFromGitHub, - fetchzip, - lib, - stdenvNoCC, - # keep-sorted end -}: -stdenvNoCC.mkDerivation rec { - pname = "bibata-cursors-gruvbox-dark"; - version = "1.1.0"; - - src = fetchFromGitHub { - owner = "adam01110"; - repo = "bibata-cursor"; - rev = "${version}"; - hash = "sha256-73DrcBnJQCmqgBs/Och034+AyexGeCULw0BDTJwMRjQ="; - }; - - bitmaps = fetchzip { - url = "https://github.com/adam01110/bibata-cursor/releases/download/${version}/Bibata-Modern-Gruvbox-Dark.zip"; - hash = "sha256-MHR5mhZJOXJsUvcJU41ZRe1dFFEcay93NbYllD4i4GM="; - }; - - nativeBuildInputs = [clickgen]; - - buildPhase = '' - runHook preBuild - - # Build xcursors - ctgen build.toml -d $bitmaps -n 'Bibata-Modern-Gruvbox-Dark' -c 'Gruvbox dark Bibata modern XCursors' - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - install -dm 0755 $out/share/icons - cp -rf bin/* $out/share/icons/ - - runHook postInstall - ''; - - meta = with lib; { - # keep-sorted start - description = "Bibata modern cursor (Gruvbox dark & left variant)"; - homepage = "https://github.com/adam01110/bibata-cursor"; - license = licenses.gpl3Only; - platforms = platforms.linux; - # keep-sorted end - }; -} diff --git a/pkgs/bibata/default.nix b/pkgs/bibata/default.nix new file mode 100644 index 0000000..a803b35 --- /dev/null +++ b/pkgs/bibata/default.nix @@ -0,0 +1,173 @@ +{ + # keep-sorted start + bash, + clickgen, + fetchFromTangled, + fetchurl, + hyprcursor, + lib, + ouch, + python3, + stdenvNoCC, + xcur2png, + # keep-sorted end +}: let + inherit + (builtins) + # keep-sorted start + fromJSON + readFile + # keep-sorted end + ; + inherit + (lib) + # keep-sorted start + mapAttrs + recurseIntoAttrs + # keep-sorted end + ; + + release = fromJSON (readFile ./release.json); + inherit + (release) + # keep-sorted start + palettes + variants + version + # keep-sorted end + ; + + src = fetchFromTangled { + did = "did:plc:v3hmlmsytt6b2zvi3e35o2oj/bibata-cursor"; + tag = version; + hash = release.sourceHash; + }; + + mkMeta = description: + with lib; { + inherit description; + homepage = "https://tangled.org/did:plc:v3hmlmsytt6b2zvi3e35o2oj/bibata-cursor"; + license = licenses.gpl3Only; + platforms = platforms.linux; + }; + + mkHyprcursor = cursor: variantName: variant: paletteName: palette: let + themeName = "Bibata-${variant.shape}-${palette.themeName}${variant.suffix}"; + description = "${palette.displayName} Bibata ${variant.displayName} Hyprcursor theme"; + in + stdenvNoCC.mkDerivation { + pname = "bibata-${variantName}-cursors-${paletteName}-hyprcursor"; + inherit + # keep-sorted start + src + version + # keep-sorted end + ; + + nativeBuildInputs = [ + # keep-sorted start + bash + hyprcursor + python3 + xcur2png + # keep-sorted end + ]; + + buildPhase = '' + runHook preBuild + + mkdir -p bin + cp -r ${cursor}/share/icons/${themeName} bin/ + chmod -R u+w bin + bash scripts/build-hyprcursor.sh + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + install -dm0755 "$out/share/icons" + cp -r "bin/${themeName}-hyprcursor" "$out/share/icons/" + + runHook postInstall + ''; + + meta = mkMeta description; + }; + + mkCursor = variantName: variant: paletteName: palette: let + themeName = "Bibata-${variant.shape}-${palette.themeName}${variant.suffix}"; + description = "${palette.displayName} Bibata ${variant.displayName} XCursor theme"; + + bitmaps = fetchurl { + url = "https://tangled.org/did:plc:v3hmlmsytt6b2zvi3e35o2oj/bibata-cursor/tags/${release.tagHash}/download/${themeName}.zip"; + hash = release.hashes.${themeName}; + }; + + hyprcursorPackage = + mkHyprcursor + # keep-sorted start + cursor + variantName + variant + paletteName + palette + # keep-sorted end + ; + + cursor = stdenvNoCC.mkDerivation { + pname = "bibata-${variantName}-cursors-${paletteName}"; + inherit + # keep-sorted start + src + version + # keep-sorted end + ; + + nativeBuildInputs = [ + # keep-sorted start + clickgen + ouch + # keep-sorted end + ]; + + buildPhase = '' + runHook preBuild + + mkdir bitmap-root + ouch decompress --yes --dir bitmap-root ${bitmaps} + ctgen ${variant.buildFile} \ + -d "bitmap-root/${themeName}" \ + -n "${themeName}" \ + -c "${description}" + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + install -dm0755 "$out/share/icons" + cp -r "bin/${themeName}" "$out/share/icons/" + + runHook postInstall + ''; + + passthru = { + ciPackages = [hyprcursorPackage]; + hyprcursor = hyprcursorPackage; + updateScript = null; + }; + + meta = mkMeta description; + }; + in + cursor; + + mkVariant = variantName: variant: + recurseIntoAttrs { + cursors = recurseIntoAttrs (mapAttrs (mkCursor variantName variant) palettes); + }; +in + mapAttrs mkVariant variants diff --git a/pkgs/bibata/release.json b/pkgs/bibata/release.json new file mode 100644 index 0000000..7732f7b --- /dev/null +++ b/pkgs/bibata/release.json @@ -0,0 +1,120 @@ +{ + "version": "2.0.3", + "tagHash": "ce8e115480c0cf0d64e1cf7f3b936f472f7e410c", + "sourceHash": "sha256-BVtmsafpEam5n2mITqCUP2JOcYn6mApK1umCDL4/H6A=", + "palettes": { + "catppuccin-frappe": { + "displayName": "Catppuccin Frappé", + "themeName": "Catppuccin-Frappe" + }, + "catppuccin-latte": { + "displayName": "Catppuccin Latte", + "themeName": "Catppuccin-Latte" + }, + "catppuccin-macchiato": { + "displayName": "Catppuccin Macchiato", + "themeName": "Catppuccin-Macchiato" + }, + "catppuccin-mocha": { + "displayName": "Catppuccin Mocha", + "themeName": "Catppuccin-Mocha" + }, + "classic": { + "displayName": "Classic", + "themeName": "Classic" + }, + "gruvbox-dark": { + "displayName": "Gruvbox dark", + "themeName": "Gruvbox-Dark" + }, + "gruvbox-light": { + "displayName": "Gruvbox light", + "themeName": "Gruvbox-Light" + }, + "rosepine": { + "displayName": "Rosé Pine", + "themeName": "Rosepine" + }, + "rosepine-dawn": { + "displayName": "Rosé Pine Dawn", + "themeName": "Rosepine-Dawn" + }, + "rosepine-moon": { + "displayName": "Rosé Pine Moon", + "themeName": "Rosepine-Moon" + } + }, + "variants": { + "modern": { + "buildFile": "build.toml", + "displayName": "modern", + "shape": "Modern", + "suffix": "" + }, + "modern-right": { + "buildFile": "build.right.toml", + "displayName": "modern right-handed", + "shape": "Modern", + "suffix": "-Right" + }, + "original": { + "buildFile": "build.toml", + "displayName": "original", + "shape": "Original", + "suffix": "" + }, + "original-right": { + "buildFile": "build.right.toml", + "displayName": "original right-handed", + "shape": "Original", + "suffix": "-Right" + } + }, + "hashes": { + "Bibata-Modern-Catppuccin-Frappe": "sha256-pdleF4e0Cho/ex85BF4WDREwdjU8h2QvdVyVSfqEQzA=", + "Bibata-Modern-Catppuccin-Frappe-Right": "sha256-Um4wRPo4sXlX+WbkHmCL067S4h2fijv81J/Q8mtDG8c=", + "Bibata-Modern-Catppuccin-Latte": "sha256-eGGgw98aTjGkvfa80kLqiVvDIb6v4lWo9YiGVXVsNDc=", + "Bibata-Modern-Catppuccin-Latte-Right": "sha256-QCOYMKM83l7TFVcAqrvJP07fV/omHGPSO9PlGvv9DaM=", + "Bibata-Modern-Catppuccin-Macchiato": "sha256-hlpqEqAw+z+ZSTR717Q+gTWGRfo4LyH8bGp8x4FfzrI=", + "Bibata-Modern-Catppuccin-Macchiato-Right": "sha256-v6olaiQCjLjpQ9s3+r/ax5XAZXryB+VwNC7Q70GKo/Q=", + "Bibata-Modern-Catppuccin-Mocha": "sha256-XuECdCPZfWR64dnW8sGtYBk79RI8h6/n5V1uYBUl4ks=", + "Bibata-Modern-Catppuccin-Mocha-Right": "sha256-XemtIEOr86BCmooVP58K8KwHyQWR547MK1PexAnhV4g=", + "Bibata-Modern-Classic": "sha256-Wmdg0WziCUY1XYr6J9PBQZExHGrghp6AQm4+wRDJdTs=", + "Bibata-Modern-Classic-Right": "sha256-3pchP2Sq8v95eg9sBQMI9X8AxpNdecIfBiBhTtAea8k=", + "Bibata-Modern-Gruvbox-Dark": "sha256-ER/pWUg7nIemwlwnibHs7kLNN0j4gH/TbuNYzlNpZKg=", + "Bibata-Modern-Gruvbox-Dark-Right": "sha256-DwrVJWF9xT62nW0yg/lH+opZPlv9m7yiXwMaimvWNRs=", + "Bibata-Modern-Gruvbox-Light": "sha256-JN1SdoDmUew6HQAS8w+Fhv5Mao+T1uj5pt88y7tXZWc=", + "Bibata-Modern-Gruvbox-Light-Right": "sha256-iJE/pJggLjAlzaWW9FqrqkJ5oE6UUMRQy4ZWJpTHBgQ=", + "Bibata-Modern-Rosepine": "sha256-/S8t6TXV3t9a5I1pQqMpd+W9JIn4A+na1JEyJHOg3co=", + "Bibata-Modern-Rosepine-Dawn": "sha256-Uz3s4Xg2vkmcrN8mYsrrhOa+bjyGr7r1N1NscQiS4P4=", + "Bibata-Modern-Rosepine-Dawn-Right": "sha256-/S1k3h6HErxJJBpCSYx79QTX7Rvkm13TvLnh1y7B33Q=", + "Bibata-Modern-Rosepine-Moon": "sha256-2OfUmQMMEGxEqPUxZmYU8877Y3MeZUYLXHxuTRvihNo=", + "Bibata-Modern-Rosepine-Moon-Right": "sha256-9nsfChrzEgMhJx/sL6x2rjVGU7EpeBLjAYXXW4unVDM=", + "Bibata-Modern-Rosepine-Right": "sha256-hZhsX5XDH1PSzj7fQE4OJkMoykAmQJhIWQBdeAiIXyI=", + "Bibata-Original-Catppuccin-Frappe": "sha256-plSx2ugXXVx2vBVqewMAbFGY0RalY/+M6khp9ipPSsk=", + "Bibata-Original-Catppuccin-Frappe-Right": "sha256-roz1q/KPYViQLiAT3lZjPQKZT1fqWyQQUf2omogTv1A=", + "Bibata-Original-Catppuccin-Latte": "sha256-9xTAYlb+RXSyuE/GWRDA7qmumADV/dDzP5IrCfs9frY=", + "Bibata-Original-Catppuccin-Latte-Right": "sha256-LthOOIBA0j38jfjCjOnpj7z88CsvbmHvPODg74pMwEY=", + "Bibata-Original-Catppuccin-Macchiato": "sha256-nhsAZpxus9dMjXp0PsokOyqf8yH9bgzTYzSODOxcEP0=", + "Bibata-Original-Catppuccin-Macchiato-Right": "sha256-vjrPxwkkeZNFOB4CjjmmXmeIPLXNW+VdoTrCGLgxC6A=", + "Bibata-Original-Catppuccin-Mocha": "sha256-twvZxo9w++dvoQq+9DV8Z8xXI1XzF6DRORnOFpYNivc=", + "Bibata-Original-Catppuccin-Mocha-Right": "sha256-MAQYeezJlJ3m2oa9HOxx+XT1DbD/wtXyZIHUHt0gKiI=", + "Bibata-Original-Classic": "sha256-kns+DXk7/FrjHi9Fcx0dRbLXyw9AtAlkxDMbZo24304=", + "Bibata-Original-Classic-Right": "sha256-gir1U0uh7KBiRNGym9bzbCCIWp61XYKPNyhtxzM7umw=", + "Bibata-Original-Gruvbox-Dark": "sha256-WLWz7cfprDItnGD8lOhMpciCmeJY5jYav3zDT3r4jro=", + "Bibata-Original-Gruvbox-Dark-Right": "sha256-/Ta1wODJNmkbTe3o0AOFk1Kp3bXhbGUISo2decY2MBg=", + "Bibata-Original-Gruvbox-Light": "sha256-EoKSu1QKS3b1iYRuVw5S9+/G8qUI0cnb6ycWsQC0vzQ=", + "Bibata-Original-Gruvbox-Light-Right": "sha256-MDbDbyCL7PPXrdZywnPpGFtu4KFmawKkdsSTQ47sseM=", + "Bibata-Original-Rosepine": "sha256-6hEEljkZ7hkbEk0wF0baJroJ6B/r0UGWsnnszfdKFXc=", + "Bibata-Original-Rosepine-Dawn": "sha256-oo7CVxM6FgnE6vo7I6sZZeX9nnmdPf9DOtxEzheddhk=", + "Bibata-Original-Rosepine-Dawn-Right": "sha256-kpwAHf9jgNXL6GS+BM9Oo6qPZS4mf5CnPmwHatj6GJs=", + "Bibata-Original-Rosepine-Moon": "sha256-KIEEpQZZXcIWivKO2tKDgrcy4vWagObHhVZZNKHDW+A=", + "Bibata-Original-Rosepine-Moon-Right": "sha256-j1ZYnIkHlnqz+kshJema8cxELY0YGZ8eXwFNqPL9gAA=", + "Bibata-Original-Rosepine-Right": "sha256-d9LXzc1IhUTaGn4PrkKzUhu87L2ayrMKxMQXalYRSok=" + }, + "updater": { + "type": "tangled-tag-artifacts", + "did": "did:plc:v3hmlmsytt6b2zvi3e35o2oj", + "repo": "bibata-cursor" + } +} diff --git a/updater/manifest.py b/updater/manifest.py index 374f2b6..7bd0917 100644 --- a/updater/manifest.py +++ b/updater/manifest.py @@ -2,8 +2,10 @@ from __future__ import annotations import json import logging +import re import tempfile import urllib.request +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -12,6 +14,7 @@ from .process import ROOT, run_json from .versions import should_block_downgrade, version_is_older DEFAULT_URL_TEMPLATE = "https://github.com/{{owner}}/{{repo}}/releases/download/{{tag}}/{{asset}}" +SUPPORTED_UPDATERS = {"github-release-assets", "tangled-tag-artifacts"} logger = logging.getLogger(__name__) @@ -20,7 +23,7 @@ def manifest_has_release_asset_updater(path: Path) -> bool: data = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): return False - return data.get("updater", {}).get("type") == "github-release-assets" + return data.get("updater", {}).get("type") in SUPPORTED_UPDATERS def package_has_manifest_updater(file_path: Path) -> Path | None: @@ -63,9 +66,17 @@ def latest_release_prefix_for_url(src_url: str | None) -> str | None: def update_release_asset_manifest(manifest: Path, *, dry_run: bool = False) -> UpdateResult: - name = str(manifest.relative_to(ROOT)) - logger.info("updating release asset manifest %s", name) data = _read_manifest(manifest) + if data.get("updater", {}).get("type") == "tangled-tag-artifacts": + return _update_tangled_tag_artifacts(manifest, data, dry_run=dry_run) + return _update_github_release_assets(manifest, data, dry_run=dry_run) + + +def _update_github_release_assets( + manifest: Path, data: dict[str, Any], *, dry_run: bool +) -> UpdateResult: + name = str(manifest.relative_to(ROOT)) + logger.info("updating GitHub release asset manifest %s", name) updater = data.get("updater", {}) owner = updater.get("owner") repo = updater.get("repo") @@ -74,53 +85,123 @@ def update_release_asset_manifest(manifest: Path, *, dry_run: bool = False) -> U assets = updater.get("assets") or {} if not owner or not repo or not assets: - return UpdateResult( - name, - "skipped", - "incomplete release asset manifest", - ) + return UpdateResult(name, "skipped", "incomplete release asset manifest") latest_tag = latest_github_release_tag(owner, repo) latest = strip_tag_prefix(latest_tag or "", tag_prefix) if not latest: return UpdateResult( - name, - "failed", - f"failed to determine latest release for {owner}/{repo}", + name, "failed", f"failed to determine latest release for {owner}/{repo}" ) - if should_block_downgrade(current, latest) and version_is_older(latest, current): + blocked = _blocked_or_current(name, current, latest) + if blocked: + return blocked + + hashes = _prefetch_github_asset_hashes(data, latest_tag or latest) + if dry_run: + return UpdateResult(name, "updated", f"manifest {current} -> {latest} (dry-run)") + + updated = dict(data) + updated["version"] = latest + updated["hashes"] = hashes + _atomic_write_json(manifest, updated) + return UpdateResult( + name, + "updated", + f"release asset manifest {current} -> {latest}", + [manifest], + ) + + +def _update_tangled_tag_artifacts( + manifest: Path, data: dict[str, Any], *, dry_run: bool +) -> UpdateResult: + name = str(manifest.relative_to(ROOT)) + logger.info("updating Tangled tag artifact manifest %s", name) + updater = data.get("updater", {}) + did = updater.get("did") + repo = updater.get("repo") + expected_assets = [f"{name}.zip" for name in data.get("hashes", {})] + current = data.get("version", "") + + if not did or not repo or not expected_assets: + return UpdateResult(name, "skipped", "incomplete Tangled artifact manifest") + + release = _latest_tangled_release(did, repo) + if not release: return UpdateResult( - name, - "skipped", - f"apparent downgrade {current} -> {latest}", + name, "failed", f"failed to determine latest Tangled release for {did}/{repo}" ) - if current == latest: + latest, tag_hash, published_assets = release + blocked = _blocked_or_current(name, current, latest) + if blocked: + return blocked + + missing = sorted(set(expected_assets) - set(published_assets)) + if missing: return UpdateResult( name, - "skipped", - f"already up to date at {current}", + "failed", + f"latest Tangled release is missing artifacts: {', '.join(missing)}", ) - hashes = _prefetch_asset_hashes(data, latest_tag or latest) + base_url = f"https://tangled.org/{did}/{repo}" + source_hash, hashes = _prefetch_tangled_release_hashes( + base_url, latest, tag_hash, expected_assets + ) if dry_run: - return UpdateResult( - name, - "updated", - f"manifest {current} -> {latest} (dry-run)", - ) + return UpdateResult(name, "updated", f"manifest {current} -> {latest} (dry-run)") updated = dict(data) updated["version"] = latest + updated["tagHash"] = tag_hash + updated["sourceHash"] = source_hash updated["hashes"] = hashes _atomic_write_json(manifest, updated) return UpdateResult( name, "updated", - f"release asset manifest {current} -> {latest}", + f"Tangled artifact manifest {current} -> {latest}", [manifest], ) +def _latest_tangled_release(did: str, repo: str) -> tuple[str, str, list[str]] | None: + url = f"https://tangled.org/{did}/{repo}/tags" + logger.info("fetching latest Tangled release for %s/%s", did, repo) + try: + with urllib.request.urlopen(url, timeout=30) as response: + html = response.read().decode() + except OSError as error: + logger.info("failed to fetch Tangled tags for %s/%s: %s", did, repo, error) + return None + + # Tangled canonicalizes DID URLs to the current handle in page links. + base_path = rf'/[^"/]+/{re.escape(repo)}' + tags = list(re.finditer(rf'href="{base_path}/tags/([^"/]+)"', html)) + if not tags: + return None + + tag = tags[0] + next_tag = next((match for match in tags[1:] if match.group(1) != tag.group(1)), None) + release_html = html[tag.end() : next_tag.start() if next_tag else None] + artifact_pattern = rf'href="{base_path}/tags/([0-9a-f]{{40}})/download/([^"/]+)"' + artifacts = re.findall(artifact_pattern, release_html) + if not artifacts: + return None + tag_hash = artifacts[0][0] + names = [asset for artifact_hash, asset in artifacts if artifact_hash == tag_hash] + return tag.group(1), tag_hash, names + + +def _blocked_or_current(name: str, current: str, latest: str) -> UpdateResult | None: + if should_block_downgrade(current, latest) and version_is_older(latest, current): + return UpdateResult(name, "skipped", f"apparent downgrade {current} -> {latest}") + if current == latest: + return UpdateResult(name, "skipped", f"already up to date at {current}") + return None + + def strip_tag_prefix(tag: str, prefix: str) -> str: return tag.removeprefix(prefix) if prefix and tag.startswith(prefix) else tag @@ -136,16 +217,37 @@ def render_asset_url(data: dict[str, Any], tag: str, asset: str) -> str: ) -def _prefetch_asset_hashes(data: dict[str, Any], tag: str) -> dict[str, str]: +def _prefetch_github_asset_hashes(data: dict[str, Any], tag: str) -> dict[str, str]: hashes: dict[str, str] = {} for system, asset in sorted(data.get("updater", {}).get("assets", {}).items()): url = render_asset_url(data, tag, asset) logger.info("prefetching %s asset %s", system, asset) - result = run_json(["nix", "store", "prefetch-file", "--json", "--hash-type", "sha256", url]) - hashes[system] = result["hash"] + hashes[system] = _prefetch_url_hash(url) return hashes +def _prefetch_tangled_release_hashes( + base_url: str, tag: str, tag_hash: str, assets: list[str] +) -> tuple[str, dict[str, str]]: + def prefetch(asset: str) -> tuple[str, str]: + logger.info("prefetching Tangled artifact %s", asset) + url = f"{base_url}/tags/{tag_hash}/download/{asset}" + return Path(asset).stem, _prefetch_url_hash(url) + + with ThreadPoolExecutor(max_workers=8) as executor: + source = executor.submit(_prefetch_url_hash, f"{base_url}/archive/{tag}", unpack=True) + hashes = dict(sorted(executor.map(prefetch, assets))) + return source.result(), hashes + + +def _prefetch_url_hash(url: str, *, unpack: bool = False) -> str: + command = ["nix", "store", "prefetch-file", "--json", "--hash-type", "sha256"] + if unpack: + command.append("--unpack") + command.append(url) + return run_json(command)["hash"] + + def _read_manifest(manifest: Path) -> dict[str, Any]: return json.loads(manifest.read_text())