From 333bb5190fa093b8a205b18e865724767750f3f1 Mon Sep 17 00:00:00 2001 From: chad-loder <26261238+chad-loder@users.noreply.github.com> Date: Tue, 12 May 2026 19:33:33 -0700 Subject: [PATCH 1/2] test(polyfill): vendor + run the WICG polyfill test corpus as a second conformance vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parallel cross-implementation conformance net beyond the upstream WPT corpus. The WICG urlpattern-polyfill bundles its own snapshot of urlpatterntestdata.json (336 entries, ~85 KB); running every entry of that snapshot against yarlpattern gives us redundant coverage of the ~328 shared cases and flags any divergence from the polyfill's expectations on the 8 entries it carries that aren't in upstream WPT. Implementation: * scripts/fetch_polyfill_corpus.sh — new fetcher mirroring the fetch_wpt_corpus.sh security model byte-for-byte: - pinned SHA (f147a0f4..., 2025-05-07) — matches the dev-side pin in scripts/fetch_references.sh - HTTPS-only sparse-checkout of just test/ - filter=blob:none clone, post-fetch git rev-parse HEAD verify - per-file size cap (10 MiB), JSON well-formedness + shape check, --verify mode for restored caches * tests/conftest.py — adds load_polyfill_cases() + polyfill_data_path (overrideable via URLPATTERN_POLYFILL_DATA), parametrizes a new polyfill_case fixture across all 336 entries. The 8 entries where the polyfill expects a constructor error but the current WHATWG spec does not (e.g. {hostname: 'bad#hostname'} — the spec accepts these with Chromium-style truncation) are wrapped in pytest.mark.skip with an explicit tracked divergence reason. Skipping the divergences is deliberate, not failure-hiding; the count surfaces in pytest output. * tests/test_polyfill.py — thin shim that delegates to the existing test_wpt_case driver. The data shape is identical, so the driver reuses without modification. * pyproject.toml — registers polyfill and wpt markers under [tool.pytest.ini_options]. Suppresses the longstanding PytestUnknownMarkWarning the WPT suite was already triggering. * .github/workflows/ci.yml — extends the wpt-corpus job to also fetch and cache the polyfill data. The artifact (renamed wpt-corpus but containing both reference/wpt and reference/polyfill) excludes both .git directories from upload to keep the Windows digest check happy. Matrix-job downloads switch to path: . (workspace root) so each reference/<...> subtree lands where the harness expects it. Test counts: Before: 580 passed, 19 skipped After: 908 passed, 27 skipped ↑ +328 polyfill cases passing +8 polyfill-vs-WHATWG-spec divergences skipped --- .github/workflows/ci.yml | 41 +++++++-- pyproject.toml | 4 + scripts/fetch_polyfill_corpus.sh | 118 ++++++++++++++++++++++++++ tests/conftest.py | 140 +++++++++++++++++++++++++++---- tests/test_polyfill.py | 34 ++++++++ 5 files changed, 313 insertions(+), 24 deletions(-) create mode 100755 scripts/fetch_polyfill_corpus.sh create mode 100644 tests/test_polyfill.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cafab2..fd90ef1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: # JSON well-formedness + shape check). ``--verify`` re-checks restored # caches so a tampered cache cannot enter the matrix. wpt-corpus: - name: Fetch WPT corpus + name: Fetch WPT + polyfill corpora needs: [plan] if: needs.plan.outputs.run-tests == 'true' runs-on: ubuntu-latest @@ -151,24 +151,36 @@ jobs: with: persist-credentials: false - name: Cache WPT corpus (key bumps when fetch script / pinned SHA changes) - id: cache + id: wpt-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: reference/wpt key: wpt-corpus-${{ hashFiles('scripts/fetch_wpt_corpus.sh') }} - name: Fetch + verify WPT corpus (cache miss) - if: steps.cache.outputs.cache-hit != 'true' + if: steps.wpt-cache.outputs.cache-hit != 'true' run: scripts/fetch_wpt_corpus.sh - - name: Re-verify restored cache (defense in depth) - if: steps.cache.outputs.cache-hit == 'true' + - name: Re-verify WPT cache (defense in depth) + if: steps.wpt-cache.outputs.cache-hit == 'true' run: scripts/fetch_wpt_corpus.sh --verify - - name: Upload corpus for matrix jobs + - name: Cache polyfill corpus (key bumps when fetch script / pinned SHA changes) + id: polyfill-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: reference/polyfill + key: polyfill-corpus-${{ hashFiles('scripts/fetch_polyfill_corpus.sh') }} + - name: Fetch + verify polyfill corpus (cache miss) + if: steps.polyfill-cache.outputs.cache-hit != 'true' + run: scripts/fetch_polyfill_corpus.sh + - name: Re-verify polyfill cache (defense in depth) + if: steps.polyfill-cache.outputs.cache-hit == 'true' + run: scripts/fetch_polyfill_corpus.sh --verify + - name: Upload corpora for matrix jobs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wpt-corpus # Sparse-checkout leaves a populated ``.git/`` (HEAD ref, refs/, # packed objects, occasional symlinks) — used by the SHA-verify - # step above but unused by matrix consumers, and a known source + # steps above but unused by matrix consumers, and a known source # of digest-mismatch errors under ``download-artifact@v8`` on # Windows runners (small files + special git entries trip the # chunked archive digest check, which v8 defaults to ``error``). @@ -177,8 +189,11 @@ jobs: # cross-OS download deterministic. path: | reference/wpt + reference/polyfill !reference/wpt/.git !reference/wpt/.git/** + !reference/polyfill/.git + !reference/polyfill/.git/** retention-days: 1 if-no-files-found: error @@ -274,7 +289,11 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wpt-corpus - path: reference/wpt + # Artifact stores files with their workspace-relative paths + # (``reference/wpt/...`` and ``reference/polyfill/...``); extracting + # to the workspace root puts them back where the test harness + # expects them. + path: . - name: Set up uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: @@ -369,7 +388,11 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wpt-corpus - path: reference/wpt + # Artifact stores files with their workspace-relative paths + # (``reference/wpt/...`` and ``reference/polyfill/...``); extracting + # to the workspace root puts them back where the test harness + # expects them. + path: . - name: Set up uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: diff --git a/pyproject.toml b/pyproject.toml index 22b7927..5ba6d32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -422,6 +422,10 @@ addopts = ["--tb=short", "--strict-markers", "--strict-config", "--import-mode=i xfail_strict = true filterwarnings = ["once::Warning"] testpaths = ["tests"] +markers = [ + "wpt: marks tests parametrized from the upstream WPT urlpattern corpus", + "polyfill: marks tests parametrized from the WICG urlpattern-polyfill corpus", +] # --------------------------------------------------------------------------- # Coverage diff --git a/scripts/fetch_polyfill_corpus.sh b/scripts/fetch_polyfill_corpus.sh new file mode 100755 index 0000000..072270e --- /dev/null +++ b/scripts/fetch_polyfill_corpus.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# scripts/fetch_polyfill_corpus.sh — CI-targeted polyfill corpus fetcher. +# +# Populates ``reference/polyfill/`` with the +# [WICG urlpattern-polyfill](https://github.com/kenchris/urlpattern-polyfill) +# test fixtures the ``tests/test_polyfill*.py`` suites consume. +# +# The polyfill is the reference JavaScript implementation of the WHATWG +# URLPattern Standard. Running its own test corpus against yarlpattern +# is a second cross-implementation conformance vector beyond the +# upstream WPT corpus that ``scripts/fetch_wpt_corpus.sh`` fetches. +# +# Security posture follows ``scripts/fetch_wpt_corpus.sh`` byte-for-byte: +# pinned SHA, HTTPS-only sparse-checkout, post-fetch SHA verification, +# per-file size cap, JSON well-formedness + shape check, ``--verify`` +# mode for re-checking restored caches. + +set -euo pipefail +umask 022 + +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +export GIT_TERMINAL_PROMPT=0 + +# ┌─────────────────────────────────────────────────────────────────────┐ +# │ Pinned upstream commit │ +# └─────────────────────────────────────────────────────────────────────┘ +# Bump in lockstep with the ``POLYFILL_REF`` in +# ``scripts/fetch_references.sh`` (the dev-side fetcher). Same convention +# as the WPT pin. +POLYFILL_REF="f147a0f42a94a29ec1dcd229b218f3a700377f91" # 2025-05-07 + +# ┌─────────────────────────────────────────────────────────────────────┐ +# │ Size cap on each parsed JSON fixture │ +# └─────────────────────────────────────────────────────────────────────┘ +# At the pinned SHA the largest fixture is ~85 KB. 10 MiB gives plenty +# of headroom without exposing a parser-DoS surface to a malicious +# upstream. +MAX_JSON_BYTES=$((10 * 1024 * 1024)) + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +POLY_DIR="$REPO_ROOT/reference/polyfill" + +EXPECTED_JSON=( + "test/urlpatterntestdata.json" + "test/urlpattern-compare-test-data.json" +) + +fatal() { + printf 'FATAL: %s\n' "$*" >&2 + exit 1 +} + +verify_json() { + local rel="$1" + local full="$POLY_DIR/$rel" + + [[ -f "$full" ]] || fatal "missing JSON fixture: $rel" + + local size + size="$(wc -c < "$full" | tr -d '[:space:]')" + + [[ "$size" =~ ^[0-9]+$ ]] || fatal "could not stat size of $rel" + (( size > 0 )) || fatal "$rel is empty" + (( size <= MAX_JSON_BYTES )) || fatal "$rel is $size bytes, exceeds cap of $MAX_JSON_BYTES" + + python3 - "$full" <<'PY' || fatal "JSON validation failed: $rel" +import json +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +data = json.loads(path.read_text(encoding="utf-8")) +if not isinstance(data, list): + raise SystemExit(f"{path.name}: top-level is {type(data).__name__}, expected list") +non_dict = [i for i, e in enumerate(data) if not isinstance(e, (dict, str))] +if non_dict: + raise SystemExit(f"{path.name}: entries at {non_dict[:5]} are not objects/strings") +PY + + printf ' ok %-60s %s bytes\n' "$rel" "$size" +} + +verify_corpus() { + printf 'Verifying polyfill corpus at %s\n' "$POLY_DIR" + for f in "${EXPECTED_JSON[@]}"; do verify_json "$f"; done + local actual_ref + actual_ref="$(git -C "$POLY_DIR" rev-parse HEAD 2>/dev/null || echo "")" + [[ "$actual_ref" == "$POLYFILL_REF" ]] \ + || fatal "POLYFILL_REF mismatch — expected $POLYFILL_REF, got $actual_ref" + printf 'Polyfill corpus integrity OK (pinned at %s)\n' "$POLYFILL_REF" +} + +if [[ "${1:-}" == "--verify" ]]; then + [[ -d "$POLY_DIR/.git" ]] || fatal "$POLY_DIR is not a git checkout (run without --verify first)" + verify_corpus + exit 0 +fi + +mkdir -p "$POLY_DIR" + +if [[ -d "$POLY_DIR/.git" ]] \ + && [[ "$(git -C "$POLY_DIR" rev-parse HEAD 2>/dev/null || true)" == "$POLYFILL_REF" ]]; then + printf 'Polyfill corpus already at %s, skipping fetch.\n' "$POLYFILL_REF" +else + if [[ ! -d "$POLY_DIR/.git" ]]; then + git clone \ + --filter=blob:none \ + --no-checkout \ + "https://github.com/kenchris/urlpattern-polyfill.git" \ + "$POLY_DIR" + fi + git -C "$POLY_DIR" sparse-checkout init --no-cone >/dev/null + git -C "$POLY_DIR" sparse-checkout set test + git -C "$POLY_DIR" fetch --filter=blob:none origin "$POLYFILL_REF" + git -C "$POLY_DIR" checkout --quiet "$POLYFILL_REF" +fi + +verify_corpus diff --git a/tests/conftest.py b/tests/conftest.py index c8fcaed..9862cb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,13 @@ from yarlpattern import COMPONENTS # re-exported for tests that need the canonical order -__all__ = ["COMPONENTS", "load_wpt_cases", "wpt_data_path"] +__all__ = [ + "COMPONENTS", + "load_polyfill_cases", + "load_wpt_cases", + "polyfill_data_path", + "wpt_data_path", +] # Locating the data file. We deliberately do NOT vendor WPT into the source tree; # `scripts/fetch_references.sh` populates `reference/wpt/`. The path is also @@ -28,12 +34,22 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent _DEFAULT_DATA = _REPO_ROOT / "reference" / "wpt" / "urlpattern" / "resources" / "urlpatterntestdata.json" +# Second cross-implementation conformance vector: the WICG urlpattern-polyfill's +# own test fixture. Populated by ``scripts/fetch_polyfill_corpus.sh``; +# overrideable via ``URLPATTERN_POLYFILL_DATA`` for downstream pinning. +_DEFAULT_POLYFILL_DATA = _REPO_ROOT / "reference" / "polyfill" / "test" / "urlpatterntestdata.json" + def wpt_data_path() -> Path: override = os.environ.get("WPT_URLPATTERN_DATA") return Path(override) if override else _DEFAULT_DATA +def polyfill_data_path() -> Path: + override = os.environ.get("URLPATTERN_POLYFILL_DATA") + return Path(override) if override else _DEFAULT_POLYFILL_DATA + + def load_wpt_cases() -> list[dict[str, Any]]: """Parse the WPT urlpattern test data file. @@ -84,12 +100,58 @@ def _case_id(idx: int, entry: dict[str, Any]) -> str: return f"{idx:03d}-{summary}" +def load_polyfill_cases() -> list[dict[str, Any]]: + """Parse the polyfill's urlpatterntestdata.json fixture. + + Structure mirrors the WPT corpus's; the polyfill's harness is itself + derived from the WPT JS runner. Most entries are byte-identical to the + WPT file (a snapshot of an older spec revision), with a small number + that diverge — those are filtered by :func:`_polyfill_diverges_from_wpt`. + """ + path = polyfill_data_path() + if not path.exists(): + msg = ( + f"polyfill urlpattern test data not found at {path}. " + "Run `scripts/fetch_polyfill_corpus.sh` to populate the corpus, " + "or set URLPATTERN_POLYFILL_DATA to point at a copy." + ) + raise FileNotFoundError(msg) + return json.loads(path.read_text(encoding="utf-8")) + + +def _polyfill_diverges_from_wpt( + polyfill_entry: dict[str, Any], wpt_by_pattern: dict[str, list[dict[str, Any]]] +) -> bool: + """True iff the polyfill expects a constructor error but WPT does not. + + The polyfill bundles an older snapshot of urlpatterntestdata.json where + a handful of pattern strings (e.g. ``{hostname: 'bad#hostname'}``) were + marked as constructor errors. The current WHATWG spec — what yarlpattern + targets — accepts these and applies Chromium-style truncation. Skipping + here keeps the polyfill suite green without forcing yarlpattern to + regress to the polyfill's older behaviour. + """ + if polyfill_entry.get("expected_obj") != "error": + return False + key = json.dumps( + {"pattern": polyfill_entry.get("pattern", [])}, + sort_keys=True, + ensure_ascii=False, + ) + candidates = wpt_by_pattern.get(key, []) + return bool(candidates) and any(c.get("expected_obj") != "error" for c in candidates) + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Apply the ``wpt`` marker to every parametrized WPT case automatically.""" + """Apply suite markers (``wpt`` / ``polyfill``) to parametrized entries.""" wpt_marker = pytest.mark.wpt + polyfill_marker = pytest.mark.polyfill for item in items: - if "wpt_case" in getattr(item, "fixturenames", ()): + fnames = getattr(item, "fixturenames", ()) + if "wpt_case" in fnames: item.add_marker(wpt_marker) + if "polyfill_case" in fnames: + item.add_marker(polyfill_marker) @pytest.fixture(scope="session") @@ -98,19 +160,67 @@ def wpt_cases() -> list[dict[str, Any]]: return load_wpt_cases() +@pytest.fixture(scope="session") +def polyfill_cases() -> list[dict[str, Any]]: + """All polyfill urlpattern cases, loaded once per session.""" + return load_polyfill_cases() + + def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Parametrize tests that request a single ``wpt_case`` over every entry. + """Parametrize tests that request a single corpus entry. - Using ``pytest_generate_tests`` (rather than a parametrize decorator on the - test function) keeps the data-loading logic centralized here, lets us - override the path via env var without churn, and makes the test function - itself a one-liner over the harness. + Centralizing the data-loading logic here lets us override paths via env + var without churn, and keeps each test function a one-liner over the + shared harness. """ - if "wpt_case" not in metafunc.fixturenames: + if "wpt_case" in metafunc.fixturenames: + cases = load_wpt_cases() + metafunc.parametrize( + "wpt_case", + cases, + ids=[_case_id(i, c) for i, c in enumerate(cases)], + ) + return + + if "polyfill_case" in metafunc.fixturenames: + # Both corpora interleave dict entries with bare-string ``//``-style + # comments; cast through ``Any`` so the isinstance guards below are + # real runtime narrowing, not redundant from the type checker's view. + wpt_cases_list: list[Any] = load_wpt_cases() + wpt_by_pattern: dict[str, list[dict[str, Any]]] = {} + for w in wpt_cases_list: + if not isinstance(w, dict) or "pattern" not in w: + continue + key = json.dumps( + {"pattern": w["pattern"]}, + sort_keys=True, + ensure_ascii=False, + ) + wpt_by_pattern.setdefault(key, []).append(w) + + polyfill: list[Any] = load_polyfill_cases() + params: list[Any] = [] + ids = [] + for i, entry in enumerate(polyfill): + if not isinstance(entry, dict): + # Polyfill data interleaves comment strings with case dicts; + # skip those without consuming a parametrize slot. + continue + if _polyfill_diverges_from_wpt(entry, wpt_by_pattern): + params.append( + pytest.param( + entry, + marks=pytest.mark.skip( + reason=( + "polyfill expects a constructor error here, but the " + "current WHATWG spec (what yarlpattern targets) does " + "not — kept in the suite as a tracked divergence" + ), + ), + ), + ) + else: + params.append(entry) + ids.append(_case_id(i, entry)) + metafunc.parametrize("polyfill_case", params, ids=ids) return - cases = load_wpt_cases() - metafunc.parametrize( - "wpt_case", - cases, - ids=[_case_id(i, c) for i, c in enumerate(cases)], - ) diff --git a/tests/test_polyfill.py b/tests/test_polyfill.py new file mode 100644 index 0000000..a0490fc --- /dev/null +++ b/tests/test_polyfill.py @@ -0,0 +1,34 @@ +"""Run every WICG urlpattern-polyfill test case against yarlpattern. + +A second cross-implementation conformance vector beyond the upstream +WPT corpus. The polyfill's ``urlpatterntestdata.json`` is a slightly +older snapshot of the WPT file plus a handful of polyfill-specific +entries; running it adds redundant coverage of the shared cases and +flags any case where yarlpattern's compiled-pattern strings differ +from the polyfill's expectations (a useful regression net for changes +to the canonicalisation layer). + +Cases where the polyfill diverges from the current WHATWG spec (the +two implementations disagree on whether some hostname / port patterns +should construct successfully) are skipped at parametrize time in +``conftest.py`` via :func:`_polyfill_diverges_from_wpt`. The skips +are deliberate and tracked, not failures hidden under a carpet. + +Driver-logic-wise, this file is a thin shim over the same +``test_wpt_case`` runner — the data shape is identical, only the +fixture is different. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .test_wpt import test_wpt_case as _run_data_corpus_case + +if TYPE_CHECKING: + import pytest + + +def test_polyfill_case(polyfill_case: dict[str, Any], request: pytest.FixtureRequest) -> None: + """Execute one parametrized polyfill urlpattern conformance entry.""" + _run_data_corpus_case(polyfill_case, request) From c11293033ab0c38a46b52c1870bc466538497dff Mon Sep 17 00:00:00 2001 From: chad-loder <26261238+chad-loder@users.noreply.github.com> Date: Tue, 12 May 2026 19:41:46 -0700 Subject: [PATCH 2/2] ci(artifact): extract wpt-corpus under reference/ to match the archive root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload-artifact with multiple paths under a common ancestor strips that ancestor from the archive root. reference/wpt and reference/polyfill share reference/ as the common ancestor, so the artifact internally stores files as wpt/... and polyfill/... (not reference/wpt/...). The matrix download was extracting with path: . which dropped files at workspace root → wpt/urlpattern/... instead of reference/wpt/urlpattern/.... Tests fail-fast on missing fixtures (per the v0.1 design), so all 10 matrix shards aborted at collection. Fix: extract under reference/ so the archive's wpt/... / polyfill/... lands at reference/wpt/... / reference/polyfill/... where conftest.py looks for them. test-prospective already worked because it runs from a fresh checkout (not the sdist + rm src/ flow). --- .github/workflows/ci.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd90ef1..00e2f89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,11 +289,13 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wpt-corpus - # Artifact stores files with their workspace-relative paths - # (``reference/wpt/...`` and ``reference/polyfill/...``); extracting - # to the workspace root puts them back where the test harness - # expects them. - path: . + # The upload-artifact step strips the common ancestor when given + # multiple paths under one parent (``reference/wpt`` and + # ``reference/polyfill`` → archive root holds ``wpt/...`` and + # ``polyfill/...``). Extract under ``reference/`` so the test + # harness finds the corpora at ``reference/wpt/...`` and + # ``reference/polyfill/...`` where conftest.py looks for them. + path: reference - name: Set up uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: @@ -388,11 +390,13 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: wpt-corpus - # Artifact stores files with their workspace-relative paths - # (``reference/wpt/...`` and ``reference/polyfill/...``); extracting - # to the workspace root puts them back where the test harness - # expects them. - path: . + # The upload-artifact step strips the common ancestor when given + # multiple paths under one parent (``reference/wpt`` and + # ``reference/polyfill`` → archive root holds ``wpt/...`` and + # ``polyfill/...``). Extract under ``reference/`` so the test + # harness finds the corpora at ``reference/wpt/...`` and + # ``reference/polyfill/...`` where conftest.py looks for them. + path: reference - name: Set up uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: