Dependabot action to consolidate PRs per dependency (#735)

Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
Glenn Jocher
2026-04-02 17:14:30 +02:00
committed by GitHub
co-authored by UltralyticsAssistant
parent ad70745c18
commit 2ca0bc3a55
2 changed files with 134 additions and 57 deletions
+82 -57
View File
@@ -81,12 +81,13 @@ def compute_update(current_ref, comment, latest):
return None
if re.fullmatch(r"v?\d+", current_ref):
# Major-only tag like @v6 -> update to latest major @v7
# Major-only tag like @v6 -> update to @v8
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
major_tag = f"{prefix}{latest_major.group(1)}"
return major_tag, comment
elif current_ref != latest_tag and int(latest_major.group(1)) >= int(current_major.group(1)):
# Specific tag like @v2.8.0 -> update to latest tag (only if same or newer major)
return latest_tag, comment
return None
@@ -118,16 +119,26 @@ def get_file_content(org, repo, path, token):
return r.text if r.status_code == 200 else None
def make_pr_title(action, old_ref, new_ref, path):
def make_pr_title(action, new_ref):
"""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}"
return f"Bump {action} to {new_ver} in /.github/workflows"
def create_single_pr(org, repo, title, file_path, new_content, token):
"""Create a single PR updating one file. Returns PR URL or None."""
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)}
def create_pr(org, repo, title, file_updates, token):
"""Create a PR updating one or more files. file_updates is list of (path, content) tuples."""
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
# Get default branch and its HEAD SHA
@@ -153,24 +164,25 @@ def create_single_pr(org, repo, title, file_path, new_content, token):
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
# Update each file on the branch (abort if any fails)
for path, content in file_updates:
r = requests.get(f"https://api.github.com/repos/{org}/{repo}/contents/{path}", headers=headers)
if r.status_code != 200:
print(f" Failed to read {path}, aborting PR")
return None
r = requests.put(
f"https://api.github.com/repos/{org}/{repo}/contents/{path}",
headers=headers,
json={
"message": f"{title} in {path}",
"content": base64.b64encode(content.encode()).decode(),
"sha": r.json()["sha"],
"branch": branch,
},
)
if r.status_code not in (200, 201):
print(f" Failed to update {path}, aborting PR")
return None
# Create PR
r = requests.post(
@@ -228,12 +240,16 @@ def run():
print(" No workflow files found")
continue
open_titles = get_open_pr_titles(org, repo_name)
# Phase 1: collect all updates grouped by (action, new_ref) across all files
# Key: (action_base_repo, new_ref) -> {title, file_contents: {path: content}}
updates = {}
file_cache = {} # path -> original content
for path in workflow_files:
content = get_file_content(org, repo_name, path, token)
if not content:
continue
file_cache[path] = content
for m in USES_PATTERN.finditer(content):
action = m.group("action")
@@ -249,25 +265,46 @@ def run():
continue
new_ref, new_comment = update
title = make_pr_title(action, f"{ref}{comment}", f"{new_ref}{new_comment}", path)
key = ("/".join(action.split("/")[:2]), new_ref)
if title in open_titles:
print(f" ⏭️ {title} (PR already exists)")
total_prs_skipped += 1
continue
if key not in updates:
updates[key] = {"new_ref": new_ref, "new_comment": new_comment, "replacements": []}
updates[key]["replacements"].append(
(path, m.start(), m.end(), m.group("indent"), action, new_ref, new_comment)
)
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() :]
if not updates:
continue
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")
# Phase 2: create one PR per action update
open_titles = get_open_pr_titles(org, repo_name)
for (action_repo, new_ref), info in updates.items():
title = make_pr_title(action_repo, f"{new_ref}{info['new_comment']}")
if title in open_titles:
print(f" ⏭️ {title} (PR already exists)")
total_prs_skipped += 1
continue
# Apply replacements per file in reverse position order to preserve offsets
file_updates = {}
for path, start, end, indent, action, nref, ncomment in sorted(
info["replacements"], key=lambda x: x[1], reverse=True
):
content = file_updates.get(path, file_cache[path])
new_line = f"{indent}{action}@{nref}{ncomment}"
file_updates[path] = content[:start] + new_line + content[end:]
print(f" {path}: {action} -> {nref}{ncomment}")
pr_url = create_pr(org, repo_name, title, list(file_updates.items()), 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)")
@@ -282,17 +319,5 @@ def run():
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()
+52
View File
@@ -0,0 +1,52 @@
# Dependabot Action
Update GitHub Actions versions across organization repos with cached version resolution. Designed for private/internal repos where GitHub's built-in Dependabot is not available.
## Usage
```yaml
name: Dependabot
on:
workflow_dispatch:
schedule:
- cron: "0 5 * * 1" # weekly on Monday at 05:00 UTC
jobs:
dependabot:
runs-on: ubuntu-latest
steps:
- uses: ultralytics/actions/dependabot@main
with:
token: ${{ secrets._GITHUB_TOKEN }}
```
## Inputs
| Input | Description | Required | Default |
| ---------- | -------------------------------------------------------------------------------- | -------- | ------------- |
| `token` | GitHub token with `contents:write`, `pull-requests:write`, and `workflow` scopes | Yes | - |
| `org` | GitHub organization name | No | `ultralytics` |
| `public` | Scan public repositories | No | `true` |
| `private` | Scan private repositories | No | `true` |
| `internal` | Scan internal repositories | No | `true` |
## How It Works
1. Lists all active repos in the org (filtered by visibility)
2. Fetches workflow files (`.github/workflows/*.yml` and `action.yml`) from each repo
3. Parses `uses:` lines and resolves the latest release for each action
4. Creates one PR per outdated action per repo, updating all files that reference it
5. Skips PRs that already exist (matches by title)
## Version Handling
| Format | Example | Behavior |
| ------------ | --------------------- | ------------------------------- |
| Branch | `@main` | Skipped |
| Major tag | `@v7` | Updated to `@v8` |
| Specific tag | `@v2.8.0` | Updated to `@v3.0.1` |
| SHA pinned | `@abc123... # v7.0.0` | SHA + comment updated to latest |
## Caching
Action versions are resolved once and reused across all repos. For an org with 60 repos using 15 common actions, this saves ~885 API calls per run.