New Dependabot action (#731)

Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
Glenn Jocher
2026-04-02 16:35:54 +02:00
committed by GitHub
co-authored by UltralyticsAssistant
parent c512f24b6e
commit ad70745c18
5 changed files with 396 additions and 23 deletions
+1
View File
@@ -16,6 +16,7 @@
# │ ├── first_interaction.py
# │ ├── review_pr.py
# │ ├── scan_prs.py
# │ ├── dependabot.py
# │ ├── summarize_pr.py
# │ ├── summarize_release.py
# │ ├── format_python_docstrings.py
+298
View File
@@ -0,0 +1,298 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""Update GitHub Actions versions across organization repositories with cached version resolution."""
import base64
import json
import os
import re
import subprocess
import requests
# Matches: `uses: owner/repo@ref` or `uses: owner/repo/path@ref` with optional `# comment`
USES_PATTERN = re.compile(
r"^(?P<indent>\s*-?\s*uses:\s*)(?P<action>[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+(?:/[a-zA-Z0-9._/-]*)?)@(?P<ref>\S+?)(?P<comment>\s+#\s*.+)?$",
re.MULTILINE,
)
def is_sha(ref):
"""Check if a reference is a full SHA (40 hex chars)."""
return bool(re.fullmatch(r"[0-9a-f]{40}", ref))
def is_branch(ref):
"""Check if a reference is likely a branch name (not a version tag or SHA)."""
return not is_sha(ref) and not re.match(r"^v?\d", ref) and "release" not in ref
def get_latest_release(action, token, cache):
"""Get latest release tag and its commit SHA for an action, using cache."""
repo = "/".join(action.split("/")[:2])
if repo in cache:
return cache[repo]
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
r = requests.get(f"https://api.github.com/repos/{repo}/releases/latest", headers=headers)
if r.status_code != 200:
print(f" Could not resolve latest version for {repo}")
return None
tag = r.json().get("tag_name", "")
if not tag:
return None
# Resolve tag to commit SHA (handles annotated tags)
sha = None
r2 = requests.get(f"https://api.github.com/repos/{repo}/git/ref/tags/{tag}", headers=headers)
if r2.status_code == 200:
obj = r2.json().get("object", {})
if obj.get("type") == "tag":
r3 = requests.get(obj["url"], headers=headers)
sha = r3.json().get("object", {}).get("sha") if r3.status_code == 200 else None
else:
sha = obj.get("sha")
cache[repo] = {"tag": tag, "sha": sha}
print(f" Cached {repo}: {tag} ({sha[:8]})" if sha else f" Cached {repo}: {tag}")
return cache[repo]
def compute_update(current_ref, comment, latest):
"""Determine the updated ref and comment for an action line.
Returns (new_ref, new_comment) or None if no update needed.
"""
if not latest:
return None
latest_tag = latest["tag"]
latest_sha = latest["sha"]
if is_sha(current_ref):
if not latest_sha or current_ref == latest_sha:
return None
return latest_sha, f" # {latest_tag}"
# Tag reference: check if there's a newer version
current_major = re.match(r"^v?(\d+)", current_ref)
latest_major = re.match(r"^v?(\d+)", latest_tag)
if not current_major or not latest_major:
return None
if re.fullmatch(r"v?\d+", current_ref):
# Major-only tag like @v6 -> update to latest major @v7
if int(latest_major.group(1)) > int(current_major.group(1)):
prefix = "v" if current_ref.startswith("v") else ""
return f"{prefix}{latest_major.group(1)}", comment
elif current_ref != latest_tag:
# Specific tag like @v2.8.0 -> update to latest tag
return latest_tag, comment
return None
def get_workflow_files(org, repo, token):
"""Fetch workflow file paths and action.yml from a repo using the GitHub API."""
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
files = []
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/contents/.github/workflows", headers=headers)
if r.status_code == 200:
files.extend(
f["path"] for f in r.json() if isinstance(f, dict) and f.get("name", "").endswith((".yml", ".yaml"))
)
for name in ("action.yml", "action.yaml"):
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/contents/{name}", headers=headers)
if r.status_code == 200:
files.append(name)
return files
def get_file_content(org, repo, path, token):
"""Fetch raw file content from a repo."""
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3.raw"}
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/contents/{path}", headers=headers)
return r.text if r.status_code == 200 else None
def make_pr_title(action, old_ref, new_ref, path):
"""Generate a Dependabot-style PR title for an action update."""
old_ver = old_ref.split("#")[-1].strip() if "#" in old_ref else old_ref
new_ver = new_ref.split("#")[-1].strip() if "#" in new_ref else new_ref
location = f"/{path}"
return f"Bump {action} from {old_ver} to {new_ver} in {location}"
def create_single_pr(org, repo, title, file_path, new_content, token):
"""Create a single PR updating one file. Returns PR URL or None."""
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
# Get default branch and its HEAD SHA
r = requests.get(f"https://api.github.com/repos/{org}/{repo}", headers=headers)
if r.status_code != 200:
return None
default_branch = r.json()["default_branch"]
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/git/ref/heads/{default_branch}", headers=headers)
if r.status_code != 200:
return None
base_sha = r.json()["object"]["sha"]
# Create branch
slug = re.sub(r"[^a-zA-Z0-9]+", "-", title).strip("-").lower()[:60]
branch = f"dependabot/github_actions/{slug}"
r = requests.post(
f"https://api.github.com/repos/{org}/{repo}/git/refs",
headers=headers,
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
)
if r.status_code not in (200, 201):
print(f" Failed to create branch: {r.json().get('message', '')}")
return None
# Update file
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/contents/{file_path}", headers=headers)
if r.status_code != 200:
return None
r = requests.put(
f"https://api.github.com/repos/{org}/{repo}/contents/{file_path}",
headers=headers,
json={
"message": title,
"content": base64.b64encode(new_content.encode()).decode(),
"sha": r.json()["sha"],
"branch": branch,
},
)
if r.status_code not in (200, 201):
print(f" Failed to update {file_path}: {r.json().get('message', '')}")
return None
# Create PR
r = requests.post(
f"https://api.github.com/repos/{org}/{repo}/pulls",
headers=headers,
json={
"title": title,
"body": f"{title}\n\nAutomated by [Ultralytics Actions](https://github.com/ultralytics/actions).",
"head": branch,
"base": default_branch,
},
)
if r.status_code in (200, 201):
return r.json().get("html_url")
print(f" Failed to create PR: {r.json().get('message', '')}")
return None
def run():
"""Update GitHub Actions versions across organization repos with cached lookups."""
token = os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")
org = os.getenv("ORG", "ultralytics")
if not token:
print("Error: GH_TOKEN or GITHUB_TOKEN required")
return
# Build visibility filter from env vars (all on by default)
visibility = {v for v in ("public", "private", "internal") if os.getenv(v.upper(), "true").lower() == "true"}
if not visibility:
visibility = {"public", "private", "internal"}
print(f"🔍 Scanning {', '.join(sorted(visibility))} repos in {org} for outdated GitHub Actions...")
result = subprocess.run(
["gh", "repo", "list", org, "--limit", "1000", "--json", "name,isArchived,visibility"],
capture_output=True,
text=True,
check=True,
)
repos = sorted(
r["name"] for r in json.loads(result.stdout) if not r["isArchived"] and r["visibility"].lower() in visibility
)
print(f"Found {len(repos)} active repos\n")
cache = {}
summary = []
total_prs_created = 0
total_prs_skipped = 0
for repo_name in repos:
print(f"📦 {org}/{repo_name}")
workflow_files = get_workflow_files(org, repo_name, token)
if not workflow_files:
print(" No workflow files found")
continue
open_titles = get_open_pr_titles(org, repo_name)
for path in workflow_files:
content = get_file_content(org, repo_name, path, token)
if not content:
continue
for m in USES_PATTERN.finditer(content):
action = m.group("action")
ref = m.group("ref")
comment = m.group("comment") or ""
if is_branch(ref):
continue
latest = get_latest_release(action, token, cache)
update = compute_update(ref, comment, latest)
if update is None:
continue
new_ref, new_comment = update
title = make_pr_title(action, f"{ref}{comment}", f"{new_ref}{new_comment}", path)
if title in open_titles:
print(f" ⏭️ {title} (PR already exists)")
total_prs_skipped += 1
continue
print(f" {path}: {action} {ref}{comment} -> {new_ref}{new_comment}")
new_line = f"{m.group('indent')}{action}@{new_ref}{new_comment}"
updated_content = content[: m.start()] + new_line + content[m.end() :]
pr_url = create_single_pr(org, repo_name, title, path, updated_content, token)
if pr_url:
print(f" ✅ Created PR: {pr_url}")
summary.append(f"- ✅ [{org}/{repo_name}]({pr_url}): {title}")
open_titles.add(title)
total_prs_created += 1
else:
print(" ❌ Failed to create PR")
print(f"\n📊 Done! Created {total_prs_created} PRs | Skipped {total_prs_skipped} (already open)")
print(f"Cached {len(cache)} action versions (saved ~{max(0, len(repos) * len(cache) - len(cache))} API lookups)")
if summary_file := os.getenv("GITHUB_STEP_SUMMARY"):
lines = [
"# 🔄 Dependabot - Update GitHub Actions\n",
f"**Repos scanned:** {len(repos)} | **PRs created:** {total_prs_created} | **Skipped:** {total_prs_skipped} | **Actions cached:** {len(cache)}\n",
*summary,
]
with open(summary_file, "a") as f:
f.write("\n".join(lines))
def get_open_pr_titles(org, repo):
"""Get titles of all open PRs in a repo using gh CLI."""
result = subprocess.run(
["gh", "pr", "list", "--repo", f"{org}/{repo}", "--state", "open", "--json", "title", "--limit", "100"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return set()
return {pr["title"] for pr in json.loads(result.stdout)}
if __name__ == "__main__":
run()
+27 -23
View File
@@ -157,34 +157,38 @@ def run():
summary.append(f"- ... {len(repo_prs) - 30} more PRs")
summary.append("")
# Auto-merge Dependabot GitHub Actions PRs
print("\n🤖 Checking for Dependabot PRs to auto-merge...")
summary.append("\n# 🤖 Auto-Merge Dependabot GitHub Actions PRs\n")
# Auto-merge GitHub Actions update PRs
print("\n🤖 Checking for GitHub Actions update PRs to auto-merge...")
summary.append("\n# 🤖 Auto-Merge GitHub Actions Update PRs\n")
total_found = total_merged = total_skipped = 0
approved_authors = ["app/dependabot", "UltralyticsAssistant"]
for repo_name in repos:
pr_list = subprocess.run(
[
"gh",
"pr",
"list",
"--repo",
f"{org}/{repo_name}",
"--author",
"app/dependabot",
"--state",
"open",
"--json",
"number,title,url,files,mergeable,statusCheckRollup",
],
capture_output=True,
text=True,
)
if pr_list.returncode != 0:
continue
# Query PRs once per approved author to avoid fetching all open PRs
all_prs = []
for author in approved_authors:
pr_list = subprocess.run(
[
"gh",
"pr",
"list",
"--repo",
f"{org}/{repo_name}",
"--author",
author,
"--state",
"open",
"--json",
"number,title,url,files,mergeable,statusCheckRollup",
],
capture_output=True,
text=True,
)
if pr_list.returncode == 0:
all_prs.extend(json.loads(pr_list.stdout))
merged = 0
for pr in json.loads(pr_list.stdout):
for pr in all_prs:
# Filter by title: must be a GitHub Actions bump PR
title = pr.get("title", "").lower()
if "bump" not in title or "/.github/workflows" not in title:
+69
View File
@@ -0,0 +1,69 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
name: "Dependabot"
author: "Ultralytics"
description: "Update GitHub Actions versions across organization repos with cached lookups. Designed for private repos where GitHub Dependabot is not available."
branding:
icon: "refresh-cw"
color: "blue"
inputs:
token:
description: "GitHub token with contents:write, pull-requests:write, and workflow permissions"
required: true
org:
description: "GitHub organization name"
required: false
default: "ultralytics"
public:
description: "Scan public repositories"
required: false
default: "true"
private:
description: "Scan private repositories"
required: false
default: "true"
internal:
description: "Scan internal repositories"
required: false
default: "true"
runs:
using: "composite"
steps:
- uses: astral-sh/setup-uv@v7
with:
ignore-empty-workdir: true
enable-cache: false
version: "0.9.4"
- name: Install ultralytics-actions
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
echo "::group::Install ultralytics-actions"
if [ "$GITHUB_REPOSITORY" = "ultralytics/actions" ]; then
echo "Installing from commit: $GITHUB_SHA"
packages="git+https://github.com/ultralytics/actions@${GITHUB_SHA}"
else
packages="git+https://github.com/ultralytics/actions@main"
fi
if [ "$(uname)" = "Darwin" ]; then
uv pip install --system --break-system-packages $packages
else
sudo env "PATH=$PATH" uv pip install --system --break-system-packages $packages
fi
echo "::endgroup::"
shell: bash
- name: Dependabot
env:
GH_TOKEN: ${{ inputs.token }}
ORG: ${{ inputs.org }}
PUBLIC: ${{ inputs.public }}
PRIVATE: ${{ inputs.private }}
INTERNAL: ${{ inputs.internal }}
run: |
echo "::group::Dependabot"
python -m actions.dependabot
echo "::endgroup::"
shell: bash
+1
View File
@@ -91,6 +91,7 @@ ultralytics-actions-summarize-release = "actions.summarize_release:main"
ultralytics-actions-update-markdown-code-blocks = "actions.update_markdown_code_blocks:main"
ultralytics-actions-headers = "actions.update_file_headers:main"
ultralytics-actions-format-python-docstrings = "actions.format_python_docstrings:main"
ultralytics-actions-dependabot = "actions.dependabot:run"
ultralytics-actions-info = "actions.utils:ultralytics_actions_info"
[tool.setuptools]