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@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: '3.14' - name: Calculate and pin two-week old stable versions shell: python run: | import json import urllib.request import re import tomllib 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() # Standard [project.dependencies] target_deps.update(parsed_toml.get("project", {}).get("dependencies", [])) # Optional [project.optional-dependencies] for deps in parsed_toml.get("project", {}).get("optional-dependencies", {}).values(): target_deps.update(deps) # Dev [dependency-groups] (uv / PEP 735 standard) 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) updates = {} two_weeks_ago = datetime.now(timezone.utc) - timedelta(days=14) print(f"Evaluating dependencies against cutoff date: {two_weeks_ago.isoformat()}") for dep in target_deps: # Isolate base package name (e.g., "yubikey-manager>=5.6.1" -> "yubikey-manager") pkg_name = re.split(r'[<>=!~;\s]', dep)[0].strip() # Preserve environment markers if they exist marker = "" if ";" in dep: marker = " ; " + dep.split(";", 1)[1].strip() print(f"Fetching PyPI data for: {pkg_name}") try: url = f"https://pypi.org/pypi/{pkg_name}/json" req = urllib.request.Request(url, headers={'User-Agent': 'GAM-CI-Script'}) with urllib.request.urlopen(req) as response: data = json.loads(response.read().decode()) valid_versions = [] for ver, files in data.get("releases", {}).items(): if not files: continue upload_time_str = files[0].get("upload_time_iso_8601") if not upload_time_str: continue if upload_time_str.endswith('Z'): upload_time_str = upload_time_str[:-1] + '+00:00' upload_time = datetime.fromisoformat(upload_time_str) # Filter: Must be older than 2 weeks and not a pre-release if upload_time <= two_weeks_ago and not any(x in ver.lower() for x in ['a', 'b', 'rc', 'dev']): valid_versions.append((upload_time, ver)) if valid_versions: # Sort by upload time descending to get the newest valid option valid_versions.sort(key=lambda x: x[0], reverse=True) target_version = valid_versions[0][1] pinned_dep = f"{pkg_name}=={target_version}{marker}" if pinned_dep != dep: updates[dep] = pinned_dep print(f" -> Pinning: '{dep}' => '{pinned_dep}'") else: print(f" -> Already pinned correctly to {target_version}") else: print(f" -> No valid historical versions found.") except urllib.error.HTTPError as e: print(f" -> Package not found on PyPI or HTTP error: {e}") except Exception as e: print(f" -> Error processing {pkg_name}: {e}") # 3. Replace the strings safely in the original file content new_content = content for old_dep, new_dep in updates.items(): # Regex targets the exact string inside either single or double quotes # Using a lambda replacement ensures we don't trip over escape sequences in the new string 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) # Write changes back to pyproject.toml 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 package versions at least 2 weeks old." branch: sys-deps-upgrade force: false # Standard push, plays nice with rulesets