New Dependabot action (#731)
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
UltralyticsAssistant
parent
c512f24b6e
commit
ad70745c18
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user