name: Daily Dependency Pinning (2-Week Buffer) on: schedule: # Runs every day at midnight UTC - cron: '0 0 * * *' workflow_dispatch: # Allows you to trigger it manually from the UI jobs: pin-deps: runs-on: ubuntu-slim permissions: contents: write pull-requests: write steps: - name: Checkout Repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v3 with: version: "latest" - name: Calculate and pin two-week old stable versions shell: python run: | import subprocess import re import tomllib import os from datetime import datetime, timedelta, timezone from pathlib import Path toml_path = Path("pyproject.toml") if not toml_path.exists(): print("pyproject.toml not found!") exit(1) content = toml_path.read_text(encoding="utf-8") parsed_toml = tomllib.loads(content) target_deps = set() # Gather all dependencies target_deps.update(parsed_toml.get("project", {}).get("dependencies", [])) for deps in parsed_toml.get("project", {}).get("optional-dependencies", {}).values(): target_deps.update(deps) for deps in parsed_toml.get("dependency-groups", {}).values(): if isinstance(deps, list): for d in deps: if isinstance(d, str): target_deps.add(d) if not target_deps: print("No dependencies found to process.") exit(0) # 1. Create a "clean" requirements list (base names + markers only, no versions) clean_reqs = [] for dep in target_deps: pkg_base = re.split(r'[<>=!~;\s]', dep)[0].strip() if ";" in dep: marker = dep.split(";", 1)[1].strip() clean_reqs.append(f"{pkg_base} ; {marker}") else: clean_reqs.append(pkg_base) temp_in = Path("temp_reqs.in") temp_in.write_text("\n".join(clean_reqs), encoding="utf-8") # 2. Calculate Cutoff Date two_weeks_ago = datetime.now(timezone.utc) - timedelta(days=14) cutoff_str = two_weeks_ago.strftime("%Y-%m-%dT%H:%M:%SZ") print(f"Resolving dependencies against cutoff date: {cutoff_str}") # 3. Use uv to resolve the CLEAN list, allowing free upgrades/downgrades try: subprocess.run([ "uv", "pip", "compile", str(temp_in), "--exclude-newer", cutoff_str, "--quiet", "-o", "resolved.txt" ], check=True) except subprocess.CalledProcessError: print("\nDependency resolution failed! Upstream constraints are impossible to satisfy.") if temp_in.exists(): temp_in.unlink() exit(1) # 4. Parse the resolved lockfile resolved_versions = {} with open("resolved.txt", "r", encoding="utf-8") as f: for line in f: line = line.split("#")[0].strip() if "==" in line: pkg, ver = line.split("==", 1) resolved_versions[pkg.strip().lower()] = ver.strip() # Cleanup temp files so they don't get committed to the PR if temp_in.exists(): temp_in.unlink() if Path("resolved.txt").exists(): Path("resolved.txt").unlink() # 5. Map the newly resolved versions back to your pyproject.toml updates updates = {} for dep in target_deps: pkg_name_raw = re.split(r'[<>=!~;\s]', dep)[0].strip() pkg_name_lower = pkg_name_raw.lower() marker = "" if ";" in dep: marker = " ; " + dep.split(";", 1)[1].strip() if pkg_name_lower in resolved_versions: target_version = resolved_versions[pkg_name_lower] pinned_dep = f"{pkg_name_raw}=={target_version}{marker}" if pinned_dep != dep: updates[dep] = pinned_dep print(f" -> Changing: '{dep}' => '{pinned_dep}'") else: print(f" -> Up to date: {dep}") # 6. Replace the strings safely in the original file content new_content = content for old_dep, new_dep in updates.items(): escaped_old = re.escape(old_dep) pattern = r'([\'"])' + escaped_old + r'\1' new_content = re.sub(pattern, lambda m: m.group(1) + new_dep + m.group(1), new_content) if content != new_content: toml_path.write_text(new_content, encoding="utf-8") print("\npyproject.toml updated successfully.") else: print("\nNo updates required.") - name: Create Pull Request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "chore: upgrade PyPi deps" title: "Upgrade PyPi deps" body: "Automated scan checking PyPI for mutually compatible package versions at least 2 weeks old. Handles both upgrades and conflict-driven downgrades." branch: sys-deps-upgrade force: false # Standard push, plays nice with rulesets