Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/dependabot-exceptions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Dependabot auto-merge exception list
#
# Packages listed here will NOT be auto-merged even for patch/minor bumps.
# They require human review because minor/patch releases in these packages
# have historically introduced breaking changes or security-sensitive behaviour.
#
# To add a package: append it under the appropriate ecosystem section.
# To remove a package: delete its entry and open a PR for review.

pip:
# Payment / billing — any change here touches money
- stripe
# Cryptography — security-critical; even patch bumps need review
- cryptography
- pyopenssl
- paramiko
# Database drivers — schema/protocol changes can be silent
- asyncpg
- psycopg2
- psycopg2-binary
- sqlalchemy
- alembic
# ML/scientific — ABI incompatibilities are common across minor bumps
- scipy
- numpy
- torch
- tensorflow
# Web frameworks — middleware behaviour changes can be subtle
- django
- flask
- fastapi
# AWS SDK — service API surface is enormous
- boto3
- botocore

actions:
# Actions that have write access to repo contents
- actions/checkout
- actions/upload-artifact
- actions/download-artifact
125 changes: 125 additions & 0 deletions .github/scripts/batch_scan_dependabot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Batch scan open Dependabot PRs and print a tier classification table.

Reads configuration from environment variables:
GITHUB_TOKEN : GitHub PAT with repo read access
GITHUB_REPOSITORY : owner/repo string
DRY_RUN : "true" / "false" (default: "true")
EXCEPTIONS_FILE : path to .github/dependabot-exceptions.yml

Writes a Markdown summary to GITHUB_STEP_SUMMARY (if set).
"""

from __future__ import annotations

import os
import re
import sys
from pathlib import Path

import yaml
from github import Github, GithubException


def _load_blocklist(exceptions_file: str) -> set[str]:
"""Return a flat set of all blocklisted package names across all ecosystems."""
p = Path(exceptions_file)
if not p.exists():
return set()
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
names: set[str] = set()
for vs in data.values():
if isinstance(vs, list):
names.update(str(v).lower() for v in vs)
return names


def _bump_kind(title: str) -> str:
m = re.search(r"\bfrom\s+(\S+)\s+to\s+(\S+)", title, re.IGNORECASE)
if not m:
return "unknown"
# Strip leading "v"/"V" so Actions-style tags (v4 → v5) are classified correctly.
old_ver = m.group(1).lstrip("vV")
new_ver = m.group(2).lstrip("vV")
old_major = re.match(r"(\d+)", old_ver)
new_major = re.match(r"(\d+)", new_ver)
if old_major and new_major and old_major.group(1) != new_major.group(1):
return "major"
old_minor = re.match(r"\d+\.(\d+)", old_ver)
new_minor = re.match(r"\d+\.(\d+)", new_ver)
if old_minor and new_minor and old_minor.group(1) != new_minor.group(1):
return "minor"
return "patch"


def _is_blocklisted(title: str, blocklist: set[str]) -> bool:
m = re.search(r"bump\s+(\S+)\s+from", title, re.IGNORECASE)
if m:
return m.group(1).lower() in blocklist
return False


def main() -> None:
token = os.environ.get("GITHUB_TOKEN")
if not token:
sys.exit("GITHUB_TOKEN not set")

repo_name = os.environ.get("GITHUB_REPOSITORY", "")
if not repo_name:
sys.exit("GITHUB_REPOSITORY not set")

dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
exceptions_file = os.environ.get(
"EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml"
)
blocklist = _load_blocklist(exceptions_file)

g = Github(token)
try:
repo = g.get_repo(repo_name)
except GithubException as exc:
sys.exit(f"Could not fetch repo {repo_name}: {exc}")

prs = list(repo.get_pulls(state="open"))
dep_prs = [
p for p in prs if p.user and p.user.login == "dependabot[bot]" and not p.draft
]

rows: list[tuple[int, str, str, str, str]] = []
for pr in dep_prs:
bump = _bump_kind(pr.title)
blocked = _is_blocklisted(pr.title, blocklist)
if blocked or bump == "major":
tier, decision = "2", "needs-review"
elif bump in ("minor", "patch"):
tier = "1"
decision = "would-auto-merge" if dry_run else "auto-merge"
else:
tier, decision = "2", "needs-review"
rows.append((pr.number, tier, decision, bump, pr.title[:60]))

summary_lines = [
"## 🤖 Dependabot Auto-Merge — Batch Scan",
"",
f"**Dry-run:** {dry_run}",
f"**Open Dependabot PRs found:** {len(dep_prs)}",
"",
"| PR | Tier | Decision | Bump | Title |",
"|----|------|----------|------|-------|",
]
for num, tier, decision, bump, title in rows:
summary_lines.append(f"| #{num} | {tier} | `{decision}` | {bump} | {title} |")

output = "\n".join(summary_lines)
print(output)

summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_file:
with open(summary_file, "a", encoding="utf-8") as f:
f.write(output + "\n")


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions .github/scripts/generate_rollback_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"""

import os
from datetime import datetime, timezone
from datetime import UTC, datetime


def main():
Expand All @@ -17,7 +17,7 @@ def main():
author = os.environ.get("AUTHOR", "unknown")
files_changed = os.environ.get("FILES_CHANGED", "").split()

timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")

path = "docs/ROLLBACK.md"
os.makedirs("docs", exist_ok=True)
Expand Down
180 changes: 180 additions & 0 deletions .github/scripts/tier_classifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Tier Classifier for Dependabot PRs.

Reads PR metadata from environment variables (set by the calling workflow or
by dependabot/fetch-metadata) and writes a tier classification + merge
decision to GITHUB_OUTPUT.

Environment variables consumed
-------------------------------
PR_TITLE : Full PR title (e.g. "chore(deps): bump stripe from 8.1.0 to 9.0.0")
UPDATE_TYPE : semver classification from dependabot/fetch-metadata:
"version-update:semver-patch", "version-update:semver-minor",
"version-update:semver-major", or empty string for non-dep PRs
DEPENDENCY_NAMES : comma-separated list of dependency names (from fetch-metadata)
PACKAGE_ECOSYSTEM : ecosystem string ("pip", "npm", "actions", …)
EXCEPTIONS_FILE : path to YAML blocklist (default: .github/dependabot-exceptions.yml)
DRY_RUN : "true" / "false" (default: "true")

Outputs written to GITHUB_OUTPUT
---------------------------------
tier : "1" (safe auto-merge), "2" (needs review)
merge_decision : "auto-merge" or "needs-review"
reason : human-readable explanation
blocklisted_names : comma-separated names that hit the blocklist (may be empty)
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

import yaml

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# Dependabot fetch-metadata reports GitHub Actions updates with ecosystem
# "github_actions"; normalise to "actions" to match the exceptions file key.
_ECOSYSTEM_ALIASES: dict[str, str] = {"github_actions": "actions"}


def _load_exceptions(path: str) -> dict[str, list[str]]:
p = Path(path)
if not p.exists():
return {}
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return {
k.lower(): [str(v).lower() for v in vs]
for k, vs in data.items()
if isinstance(vs, list)
}


def _is_blocklisted(
dep_names: list[str],
ecosystem: str,
exceptions: dict[str, list[str]],
) -> list[str]:
blocked: list[str] = []
# Normalise ecosystem name (e.g. "github_actions" → "actions").
normalised = ecosystem.lower() if ecosystem else ""
ecosystem_key = _ECOSYSTEM_ALIASES.get(normalised, normalised)
blocklist = exceptions.get(ecosystem_key, [])
for name in dep_names:
if name.lower() in blocklist:
blocked.append(name)
return blocked


def _write_outputs(outputs: dict[str, str]) -> None:
out_file = os.environ.get("GITHUB_OUTPUT")
if out_file:
with open(out_file, "a", encoding="utf-8") as f:
for key, val in outputs.items():
f.write(f"{key}={val}\n")
for key, val in outputs.items():
print(f" {key}={val}")


def _write_summary(lines: list[str]) -> None:
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
content = "\n".join(lines)
print(content)
if summary_file:
with open(summary_file, "a", encoding="utf-8") as f:
f.write(content + "\n")


# ---------------------------------------------------------------------------
# Main classification logic
# ---------------------------------------------------------------------------


def classify() -> None:
pr_title = os.environ.get("PR_TITLE", "")
update_type = os.environ.get("UPDATE_TYPE", "")
dependency_names_raw = os.environ.get("DEPENDENCY_NAMES", "")
ecosystem = os.environ.get("PACKAGE_ECOSYSTEM", "pip")
exceptions_file = os.environ.get(
"EXCEPTIONS_FILE", ".github/dependabot-exceptions.yml"
)
dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"
Comment on lines +100 to +106

dep_names = [n.strip() for n in dependency_names_raw.split(",") if n.strip()]

exceptions = _load_exceptions(exceptions_file)
blocklisted = _is_blocklisted(dep_names, ecosystem, exceptions)

# Determine update class from fetch-metadata output
is_patch = "semver-patch" in update_type
is_minor = "semver-minor" in update_type
is_major = "semver-major" in update_type

# Classification
if blocklisted:
tier = "2"
merge_decision = "needs-review"
reason = (
f"Blocklisted package(s) require human review: " f"{', '.join(blocklisted)}"
)
elif is_major:
tier = "2"
merge_decision = "needs-review"
reason = "Major version bump detected — potential breaking changes."
elif is_patch or is_minor:
tier = "1"
merge_decision = "auto-merge"
reason = (
f"{'Patch' if is_patch else 'Minor'} version bump from a "
f"non-blocklisted package — safe to auto-merge."
)
else:
# Unknown / non-Dependabot PR or no update-type info
tier = "2"
merge_decision = "needs-review"
reason = (
"Non-Dependabot PR or unrecognised update type "
f"(update_type={update_type!r}) — routing to human review."
)

summary_lines = [
"## 🤖 Tier Classifier Result",
"",
f"**PR title:** {pr_title}",
f"**Dependencies:** {', '.join(dep_names) or '(none detected)'}",
f"**Ecosystem:** {ecosystem}",
f"**Update type:** {update_type or '(unknown)'}",
f"**Blocklisted:** {', '.join(blocklisted) or 'none'}",
"",
f"### Decision: Tier {tier} — `{merge_decision}`",
f"**Reason:** {reason}",
"",
f"**Dry-run mode:** {dry_run}",
]
if dry_run:
summary_lines.append(
"\n> ⚠️ Dry-run is ON — merge command will be logged but not executed."
)

_write_summary(summary_lines)
_write_outputs(
{
"tier": tier,
"merge_decision": merge_decision,
"reason": reason.replace("\n", " "),
"blocklisted_names": ",".join(blocklisted),
}
)


if __name__ == "__main__":
try:
classify()
except (OSError, yaml.YAMLError) as exc:
print(f"::error::tier_classifier.py I/O or YAML error: {exc}", file=sys.stderr)
sys.exit(1)
Loading