feat(ci): add scheduled dependency-bump workflows (Renovate-lite)
CI / Sync project version with tag (hand-pushed tags only) (pull_request) Skipped
CI / Publish package to Gitea package registry (pull_request) Skipped
CI / Lint (ruff check) (pull_request) Successful in 1m28s
CI / Type check (ty) (pull_request) Successful in 1m28s
CI / Format (ruff format) (pull_request) Successful in 1m28s
CI / Tests (pull_request) Successful in 3m34s
CI / Release (bump, changelog, badges, tag) on merge to master (pull_request) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEer5EWzFU6NJi8hTnDe1
This commit is contained in:
2026-09-07 16:59:41 +02:00
co-authored by Claude Sonnet 5
parent e24907862f
commit acd2350f51
8 changed files with 571 additions and 0 deletions
+147
View File
@@ -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)