Dependabot action to consolidate PRs per dependency (#735)
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
UltralyticsAssistant
parent
ad70745c18
commit
2ca0bc3a55
+82
-57
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user