From acd2350f5168d9fa67ad2c670547327c27ca412f Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Mon, 7 Sep 2026 16:59:41 +0200 Subject: [PATCH] feat(ci): add scheduled dependency-bump workflows (Renovate-lite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two Gitea Actions scheduled workflows, each opening/updating one standing pull request rather than pushing to master or opening an issue, so the existing pull_request CI (lint/format/type-check/tests) gates every change before a human merges: - deps-lock.yml (weekly, Mondays): `uv lock --upgrade` within the existing pyproject.toml constraints, PR'd on chore/uv-lock-upgrade. - deps-bounds.yml (monthly): raises pyproject.toml upper bounds that have fallen behind the latest PyPI release, re-locks, PR'd on chore/dep-bounds. torch (pinned <2.4 for portal-machine driver support), plotstyle (private index), and the giant[...] self-references are permanently excluded. Both branches are force-pushed fresh from master each run (no history accumulation, at most one open PR per job), sharing PR-upsert mechanics in deps-pr.sh. Both support workflow_dispatch for manual testing. No changes to ci.yml, release-commit.sh, .bumpversion.toml, or cliff.toml — merging either PR flows through the existing release job unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KhEer5EWzFU6NJi8hTnDe1 --- .gitea/scripts/check_dep_bounds.py | 184 ++++++++++++++++++++++++++++ .gitea/scripts/deps-bounds-pr.sh | 44 +++++++ .gitea/scripts/deps-lock-pr.sh | 48 ++++++++ .gitea/scripts/deps-pr.sh | 84 +++++++++++++ .gitea/workflows/deps-bounds.yml | 29 +++++ .gitea/workflows/deps-lock.yml | 29 +++++ CLAUDE.md | 6 + tests/test_dependency_automation.py | 147 ++++++++++++++++++++++ 8 files changed, 571 insertions(+) create mode 100755 .gitea/scripts/check_dep_bounds.py create mode 100755 .gitea/scripts/deps-bounds-pr.sh create mode 100755 .gitea/scripts/deps-lock-pr.sh create mode 100755 .gitea/scripts/deps-pr.sh create mode 100644 .gitea/workflows/deps-bounds.yml create mode 100644 .gitea/workflows/deps-lock.yml create mode 100644 tests/test_dependency_automation.py diff --git a/.gitea/scripts/check_dep_bounds.py b/.gitea/scripts/check_dep_bounds.py new file mode 100755 index 0000000..c89d0b7 --- /dev/null +++ b/.gitea/scripts/check_dep_bounds.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Check (and optionally raise) pyproject.toml's dependency upper bounds +against the latest release on PyPI. + +Used by the monthly deps-bounds workflow (see deps-bounds-pr.sh): a lockfile +refresh (deps-lock.yml, weekly) only ever moves within the existing +`>=..,<..` constraints, so a stale upper bound never gets touched by that +job. This script closes that gap by proposing (or applying) a raised +ceiling, always as its own reviewable PR — a deliberately excluded package +(torch, pinned to a driver-compatible range; plotstyle, not on PyPI at all) +never gets edited. + +Standalone stdlib + `packaging` script (no project deps needed to run it): + uv run --no-project --with packaging python check_dep_bounds.py --report - + uv run --no-project --with packaging python check_dep_bounds.py --apply --report bounds.md +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +from packaging.requirements import Requirement +from packaging.version import Version + +ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = ROOT / "pyproject.toml" + +# Deliberately never touched by this script, with the reason surfaced in the +# report footer. +SKIP_REASONS = { + "giant": "self-reference (extras-of-extras), not a real upper bound to check", + "torch": "pinned <2.4 deliberately — newer torch needs newer NVIDIA drivers" + " than the shared portal machines have (see CLAUDE.md)", + "plotstyle": "served from the private `larsbogner` index, not PyPI", +} + + +@dataclass +class Finding: + name: str + old_specifier: str + new_specifier: str + latest: str + + +def iter_requirement_strings(pyproject: dict) -> list[str]: + project = pyproject["project"] + reqs = list(project.get("dependencies", [])) + for group_reqs in project.get("optional-dependencies", {}).values(): + reqs.extend(group_reqs) + return reqs + + +def canonical_requirements(pyproject: dict) -> dict[str, Requirement]: + """One Requirement per distinct package name (specifiers are expected to + agree across groups — that's true today; a future mismatch would just + mean the last-seen group's specifier gets checked, which is fine for a + monthly advisory script).""" + out: dict[str, Requirement] = {} + for req_str in iter_requirement_strings(pyproject): + req = Requirement(req_str) + out[req.name] = req + return out + + +def fetch_latest_version(name: str) -> str | None: + url = f"https://pypi.org/pypi/{name}/json" + try: + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.load(resp) + return data["info"]["version"] + except Exception as exc: # noqa: BLE001 - network hiccup: degrade, don't fail the job + print(f"warning: could not fetch latest version for {name}: {exc}", file=sys.stderr) + return None + + +def next_ceiling(latest: Version) -> str: + """The upper-bound scheme already used by this repo's own pins: + `<{major+1}` once a package is past 0.x (e.g. numpy>=1.26,<3), but + `<0.{minor+1}` while still pre-1.0 (e.g. ruff>=0.15,<1 -> next would be + <0.17 style if ruff were still 0.x; ty>=0.0.50,<0.1 is the same idea one + level deeper). Only the major (or, pre-1.0, the minor) component of the + latest release matters here — the point is "next breaking-change + boundary", not "exactly latest.patch + epsilon". + """ + if latest.major == 0: + return f"<0.{latest.minor + 1}" + return f"<{latest.major + 1}" + + +def find_findings(requirements: dict[str, Requirement], skip: dict[str, str]) -> list[Finding]: + findings = [] + for name, req in sorted(requirements.items()): + if name in skip: + continue + latest_str = fetch_latest_version(name) + if latest_str is None: + continue + latest = Version(latest_str) + if latest in req.specifier: + continue + new_upper = next_ceiling(latest) + lower_clauses = [str(s) for s in req.specifier if s.operator != "<"] + new_specifier = ",".join([*lower_clauses, new_upper]) + findings.append( + Finding(name=name, old_specifier=str(req.specifier), new_specifier=new_specifier, latest=latest_str) + ) + return findings + + +def apply_findings(text: str, findings: list[Finding]) -> str: + """Textual, in-place replacement of each finding's specifier substring — + deliberately not a TOML round-trip, so comments/formatting/key order in + pyproject.toml survive untouched. Every occurrence is replaced (a name + like polars appears in three separate dependency groups with identical + specifiers, and all of them must move together).""" + for finding in findings: + # Matched by package name rather than the old specifier string + # verbatim: packaging.requirements.Requirement's str(specifier) + # doesn't preserve clause order (e.g. "numpy>=1.26,<3" round-trips + # as "<3,>=1.26"), so an exact-string match on the old requirement + # would rarely hit. subn with no count replaces every occurrence in + # one pass, which is what a multi-group dependency (e.g. polars) + # needs. + new = f'"{finding.name}{finding.new_specifier}"' + # Negative lookahead guards against matching a longer package name + # sharing this one as a prefix (e.g. "numpy" must not match + # "numpydoc>=..."). + pattern = re.compile(rf'"{re.escape(finding.name)}(?![\w.-])[^"]*"') + text, n = pattern.subn(new, text) + if n == 0: + print(f"warning: could not locate {finding.name!r} requirement string to rewrite", file=sys.stderr) + return text + + +def render_report(findings: list[Finding], skip: dict[str, str]) -> str: + lines = [] + if findings: + lines.append("| package | old constraint | new constraint | latest on PyPI |") + lines.append("| --- | --- | --- | --- |") + for f in findings: + lines.append(f"| {f.name} | `{f.old_specifier}` | `{f.new_specifier}` | {f.latest} |") + else: + lines.append("All checked dependency upper bounds already cover the latest PyPI release.") + lines.append("") + lines.append("Skipped (never auto-raised):") + for name, reason in sorted(skip.items()): + lines.append(f"- `{name}` — {reason}") + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="rewrite pyproject.toml in place") + parser.add_argument("--report", default="-", help="write the markdown report here ('-' for stdout, default: -)") + args = parser.parse_args() + + pyproject_text = PYPROJECT.read_text() + pyproject = tomllib.loads(pyproject_text) + requirements = canonical_requirements(pyproject) + findings = find_findings(requirements, SKIP_REASONS) + + report = render_report(findings, SKIP_REASONS) + if args.report == "-": + print(report, end="") + else: + Path(args.report).write_text(report) + + if args.apply and findings: + new_text = apply_findings(pyproject_text, findings) + PYPROJECT.write_text(new_text) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitea/scripts/deps-bounds-pr.sh b/.gitea/scripts/deps-bounds-pr.sh new file mode 100755 index 0000000..1f1ca2d --- /dev/null +++ b/.gitea/scripts/deps-bounds-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Monthly job: raise pyproject.toml upper bounds that have fallen behind the +# latest PyPI release (see check_dep_bounds.py — torch/plotstyle/giant are +# deliberately excluded there), re-lock, and open/update a PR with the +# result. Separate from the weekly uv.lock-only refresh (deps-lock.yml) +# because this one can legitimately break CI (a new major version), which +# should never block the routine weekly lockfile bump. +# +# Preconditions: repo checked out on master with fetch-depth: 0, uv synced +# (./.gitea/actions/setup), CI_TOKEN/GITHUB_* env set by Gitea Actions. +set -euo pipefail + +BRANCH="chore/dep-bounds" + +git config user.name "gitea-actions" +git config user.email "actions@git.larsbogner.de" +git checkout -B "$BRANCH" origin/master + +BODY_FILE=$(mktemp) +uv run --no-project --with packaging python .gitea/scripts/check_dep_bounds.py \ + --apply --report "$BODY_FILE" + +if git diff --quiet -- pyproject.toml; then + echo "No upper bounds out of date; nothing to propose" + .gitea/scripts/deps-pr.sh close "$BRANCH" + exit 0 +fi + +# Re-lock so the PR carries a pyproject + uv.lock that agree. +uv lock + +N_RAISED=$(grep -c '^| ' "$BODY_FILE" || true) +# Subtract the header + separator row from the markdown table, if present. +if [ "$N_RAISED" -ge 2 ]; then + N_RAISED=$((N_RAISED - 2)) +else + N_RAISED=0 +fi + +git add pyproject.toml uv.lock +.gitea/scripts/deps-pr.sh open "$BRANCH" \ + "chore(deps): raise dependency upper bounds" \ + "chore(deps): raise dependency upper bounds ($N_RAISED packages)" \ + "$BODY_FILE" diff --git a/.gitea/scripts/deps-lock-pr.sh b/.gitea/scripts/deps-lock-pr.sh new file mode 100755 index 0000000..3c45720 --- /dev/null +++ b/.gitea/scripts/deps-lock-pr.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Weekly job: refresh uv.lock against the pinned constraints in +# pyproject.toml (no constraint edits — see check_dep_bounds.py for the +# separate monthly job that raises upper bounds) and open/update a PR with +# the result. See deps-pr.sh for the commit/push/PR-upsert mechanics. +# +# Preconditions: repo checked out on master with fetch-depth: 0, uv synced +# (./.gitea/actions/setup), CI_TOKEN/GITHUB_* env set by Gitea Actions. +set -euo pipefail + +BRANCH="chore/uv-lock-upgrade" + +git config user.name "gitea-actions" +git config user.email "actions@git.larsbogner.de" +git checkout -B "$BRANCH" origin/master + +UPDATES_FILE=$(mktemp) +uv lock --upgrade 2>&1 | tee "$UPDATES_FILE" + +if git diff --quiet -- uv.lock; then + echo "uv.lock already up to date; nothing to propose" + .gitea/scripts/deps-pr.sh close "$BRANCH" + exit 0 +fi + +N_PACKAGES=$(grep -c '^Update ' "$UPDATES_FILE" || true) + +BODY_FILE=$(mktemp) +{ + if [ "$N_PACKAGES" -gt 0 ]; then + echo "Weekly automated \`uv lock --upgrade\` — updates within the existing" + echo "\`pyproject.toml\` constraints:" + echo + grep '^Update ' "$UPDATES_FILE" | sed 's/^/- /' + else + echo "Weekly automated \`uv lock --upgrade\` refreshed the lockfile (e.g. hash" + echo "or metadata changes) without a visible version bump." + fi + echo + echo "CI on this PR (lint/format/type-check/tests) is the gate; merge normally" + echo "once green, which triggers the usual patch release." +} > "$BODY_FILE" + +git add uv.lock +.gitea/scripts/deps-pr.sh open "$BRANCH" \ + "chore(deps): weekly uv.lock refresh" \ + "chore(deps): weekly uv.lock refresh ($N_PACKAGES packages)" \ + "$BODY_FILE" diff --git a/.gitea/scripts/deps-pr.sh b/.gitea/scripts/deps-pr.sh new file mode 100755 index 0000000..38af9a1 --- /dev/null +++ b/.gitea/scripts/deps-pr.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Shared commit/push/PR-upsert mechanics for the scheduled dependency-bump +# workflows (deps-lock.yml, deps-bounds.yml). Both jobs stage some changes, +# then delegate here to turn them into a standing pull request against +# master — one stable branch per job, force-pushed every run, so the PR +# stays a single commit and a single open proposal across weeks/months +# instead of accumulating history or duplicate PRs. +# +# Usage: +# deps-pr.sh open +# Commit the currently staged changes, force-push , and +# create-or-update an open PR from onto master. +# deps-pr.sh close +# Close any open PR from onto master (if one exists) and +# delete the remote branch. Used when a run finds nothing to change. +# +# Preconditions: repo checked out with fetch-depth: 0, git user.name/email +# already configured, GITHUB_SERVER_URL/GITHUB_REPOSITORY/CI_TOKEN set (all +# provided by Gitea Actions), and — for "open" — the changes to publish are +# already `git add`-ed. +set -euo pipefail + +MODE="${1:?usage: deps-pr.sh open|close ...}" +BRANCH="${2:?branch name required}" + +API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" +AUTH_HEADER="Authorization: token ${CI_TOKEN}" + +# Look up the currently open PR (if any) from $BRANCH onto master. +find_open_pr() { + curl -sf -H "$AUTH_HEADER" "${API}/pulls?state=open&base=master" \ + | jq -r --arg ref "$BRANCH" '.[] | select(.head.ref == $ref) | .number' \ + | head -n1 +} + +case "$MODE" in + open) + SUBJECT="${3:?commit subject required}" + TITLE="${4:?PR title required}" + BODY_FILE="${5:?PR body file required}" + + git commit -F - < /dev/null + else + echo "Opening new PR from $BRANCH" + curl -sf -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \ + "${API}/pulls" \ + -d "{\"head\": \"${BRANCH}\", \"base\": \"master\", \"title\": $(jq -Rs '.' <<<"$TITLE"), \"body\": ${BODY_JSON}}" \ + > /dev/null + fi + ;; + + close) + PR_NUMBER=$(find_open_pr || true) + if [ -n "$PR_NUMBER" ]; then + echo "Closing PR #$PR_NUMBER from $BRANCH (nothing to update)" + curl -sf -X PATCH -H "$AUTH_HEADER" -H "Content-Type: application/json" \ + "${API}/pulls/${PR_NUMBER}" -d '{"state": "closed"}' > /dev/null + else + echo "No open PR from $BRANCH to close" + fi + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + git push origin --delete "$BRANCH" || echo "Could not delete remote branch $BRANCH (already gone?)" + fi + ;; + + *) + echo "Unknown mode: $MODE (expected 'open' or 'close')" >&2 + exit 1 + ;; +esac diff --git a/.gitea/workflows/deps-bounds.yml b/.gitea/workflows/deps-bounds.yml new file mode 100644 index 0000000..820d96d --- /dev/null +++ b/.gitea/workflows/deps-bounds.yml @@ -0,0 +1,29 @@ +name: Monthly dependency upper-bound raise + +"on": + schedule: + - cron: "0 5 1 * *" + workflow_dispatch: {} + +env: + UV_CACHE_DIR: /uv-cache + +jobs: + bounds-upgrade: + name: raise stale pyproject ceilings -> PR + runs-on: ubuntu-latest + container: + image: docker.gitea.com/runner-images:ubuntu-latest + volumes: + - /srv/act-runner-cache/uv:/uv-cache + steps: + # CI_TOKEN needs write:repository (same scope the release job uses) + # to push the refresh branch and open/update its PR. + - uses: actions/checkout@v4 + with: + token: ${{ secrets.CI_TOKEN }} + fetch-depth: 0 + - uses: ./.gitea/actions/setup + - run: .gitea/scripts/deps-bounds-pr.sh + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} diff --git a/.gitea/workflows/deps-lock.yml b/.gitea/workflows/deps-lock.yml new file mode 100644 index 0000000..30bc458 --- /dev/null +++ b/.gitea/workflows/deps-lock.yml @@ -0,0 +1,29 @@ +name: Weekly dependency lock refresh + +"on": + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: {} + +env: + UV_CACHE_DIR: /uv-cache + +jobs: + lock-upgrade: + name: uv lock --upgrade -> PR + runs-on: ubuntu-latest + container: + image: docker.gitea.com/runner-images:ubuntu-latest + volumes: + - /srv/act-runner-cache/uv:/uv-cache + steps: + # CI_TOKEN needs write:repository (same scope the release job uses) + # to push the refresh branch and open/update its PR. + - uses: actions/checkout@v4 + with: + token: ${{ secrets.CI_TOKEN }} + fetch-depth: 0 + - uses: ./.gitea/actions/setup + - run: .gitea/scripts/deps-lock-pr.sh + env: + CI_TOKEN: ${{ secrets.CI_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 4833588..dcc98c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,12 @@ Part of the `dev` extra. Run these periodically (not just at commit time) to cat Merges to `master` auto-bump the patch version, tag, and update `CHANGELOG.md` via the Gitea workflow in `.gitea/workflows/ci.yml` (bump-my-version + git-cliff). Don't hand-edit the version in `pyproject.toml` or write changelog entries by hand. +Two scheduled Gitea workflows keep dependencies current, each opening/updating one standing pull request (never editing `master` directly, never opening a Gitea issue) so the normal `pull_request` CI (lint/format/type-check/tests) gates every change before a human merges it: +- **`deps-lock.yml`** (weekly, Mondays) — `uv lock --upgrade` within the existing `pyproject.toml` constraints, PR'd on branch `chore/uv-lock-upgrade` (`.gitea/scripts/deps-lock-pr.sh`). +- **`deps-bounds.yml`** (monthly) — raises `pyproject.toml` upper bounds that have fallen behind the latest PyPI release, re-locks, and PRs on branch `chore/dep-bounds` (`.gitea/scripts/deps-bounds-pr.sh`, driving `.gitea/scripts/check_dep_bounds.py`). `torch` (pinned `<2.4` for portal-machine driver support), `plotstyle` (private index, not on PyPI), and the `giant[...]` self-references are never auto-raised — see `check_dep_bounds.py`'s `SKIP_REASONS`. + +Both scripts share PR-upsert mechanics in `.gitea/scripts/deps-pr.sh`; both branches are force-pushed fresh from `master` each run rather than accumulated, so at most one open PR exists per job at a time. Both can be triggered by hand via `workflow_dispatch` in the Gitea Actions UI. + ## Compute environment Work on this repo happens across three kinds of machine: diff --git a/tests/test_dependency_automation.py b/tests/test_dependency_automation.py new file mode 100644 index 0000000..8330626 --- /dev/null +++ b/tests/test_dependency_automation.py @@ -0,0 +1,147 @@ +"""Tests for the scheduled dependency-bump automation (weekly uv.lock +refresh, monthly pyproject upper-bound raise). The workflow YAMLs themselves +can only be exercised by a real scheduled/dispatched run (same reasoning as +tests/test_release_tooling.py for the release workflow), so this checks the +script logic they drive plus the one piece of cross-file coupling that would +silently misbehave if it drifted: each workflow's script must target the +same standing branch that deps-pr.sh (invoked by that script) manages. +""" + +import importlib.util +import re +import sys +import tomllib +from pathlib import Path + +import pytest +import yaml +from packaging.requirements import Requirement +from packaging.version import Version + +_ROOT = Path(__file__).resolve().parents[1] +_SCRIPTS = _ROOT / ".gitea" / "scripts" +_WORKFLOWS = _ROOT / ".gitea" / "workflows" + + +def _load_check_dep_bounds(): + spec = importlib.util.spec_from_file_location("check_dep_bounds", _SCRIPTS / "check_dep_bounds.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def cdb(): + return _load_check_dep_bounds() + + +def test_canonical_requirements_finds_real_dependencies_and_skips_excluded(cdb): + pyproject = tomllib.loads((_ROOT / "pyproject.toml").read_text()) + requirements = cdb.canonical_requirements(pyproject) + + assert "numpy" in requirements + assert "polars" in requirements + # polars appears in [project.dependencies], [convert], and [analysis] + # with the same specifier — canonicalization must dedupe by name. + assert isinstance(requirements["polars"], Requirement) + + for skipped in ("torch", "plotstyle", "giant"): + assert skipped in cdb.SKIP_REASONS, f"{skipped} must stay on the never-auto-raise skip list" + + +def test_next_ceiling_pre_and_post_1_0(): + from importlib import import_module + + cdb = import_module("check_dep_bounds") + # Post-1.0: next ceiling is the major above latest. + assert cdb.next_ceiling(Version("2.5.3")) == "<3" + assert cdb.next_ceiling(Version("25.0.1")) == "<26" + # Pre-1.0: next ceiling is the minor above latest (matches this repo's + # own pins, e.g. ruff>=0.15,<1 and ty>=0.0.50,<0.1). + assert cdb.next_ceiling(Version("0.16.2")) == "<0.17" + + +def test_find_findings_flags_out_of_range_and_skips_excluded(cdb, monkeypatch): + requirements = { + "numpy": Requirement("numpy>=1.26,<3"), + "widget": Requirement("widget>=1,<2"), + "torch": Requirement("torch>=2.3,<2.4"), + } + fake_latest = {"numpy": "2.5.3", "widget": "3.1.0", "torch": "2.9.0"} + monkeypatch.setattr(cdb, "fetch_latest_version", lambda name: fake_latest[name]) + + findings = cdb.find_findings(requirements, {"torch": "pinned deliberately"}) + + assert len(findings) == 1 + finding = findings[0] + assert finding.name == "widget" + assert set(finding.old_specifier.split(",")) == {">=1", "<2"} + # SpecifierSet doesn't guarantee clause order, so compare as a set. + assert set(finding.new_specifier.split(",")) == {">=1", "<4"} + assert finding.latest == "3.1.0" + + +def test_apply_findings_rewrites_every_occurrence_and_nothing_else(cdb): + text = ( + "[project]\n" + "dependencies = [\n" + ' "polars>=1.43,<2",\n' + "]\n\n" + "[project.optional-dependencies]\n" + "convert = [\n" + ' "polars>=1.43,<2",\n' + "]\n" + "other = [\n" + ' "numpy>=1.26,<3",\n' + "]\n" + ) + finding = cdb.Finding(name="polars", old_specifier=">=1.43,<2", new_specifier=">=1.43,<3", latest="2.0.0") + + new_text = cdb.apply_findings(text, [finding]) + + assert new_text.count('"polars>=1.43,<3"') == 2 + assert '"polars>=1.43,<2"' not in new_text + # Untouched dependency (numpy) survives byte-for-byte. + assert '"numpy>=1.26,<3"' in new_text + + +def test_apply_findings_on_real_pyproject_is_a_noop_when_no_findings(cdb): + text = (_ROOT / "pyproject.toml").read_text() + assert cdb.apply_findings(text, []) == text + + +@pytest.mark.parametrize( + ("workflow_file", "script_file"), + [ + ("deps-lock.yml", "deps-lock-pr.sh"), + ("deps-bounds.yml", "deps-bounds-pr.sh"), + ], +) +def test_workflow_yaml_parses_and_calls_matching_script(workflow_file, script_file): + workflow = yaml.safe_load((_WORKFLOWS / workflow_file).read_text()) + assert "schedule" in workflow["on"] + assert "workflow_dispatch" in workflow["on"] + + workflow_text = (_WORKFLOWS / workflow_file).read_text() + assert script_file in workflow_text, f"{workflow_file} must invoke .gitea/scripts/{script_file}" + + +@pytest.mark.parametrize( + ("script_file", "branch"), + [ + ("deps-lock-pr.sh", "chore/uv-lock-upgrade"), + ("deps-bounds-pr.sh", "chore/dep-bounds"), + ], +) +def test_driver_script_branch_matches_deps_pr_invocations(script_file, branch): + """Each driver script must define BRANCH as the expected literal, then + route every git/deps-pr.sh call through that one $BRANCH variable — a + hardcoded mismatch would silently leave a stray branch/PR or open a + second one each run.""" + text = (_SCRIPTS / script_file).read_text() + assert f'BRANCH="{branch}"' in text, f"{script_file} should set BRANCH={branch!r}" + assert 'git checkout -B "$BRANCH"' in text + assert re.search(r'deps-pr\.sh open "\$BRANCH"', text) + assert re.search(r'deps-pr\.sh close "\$BRANCH"', text)