From ad70745c185d18c896d69eaa9b79aef855d444d2 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Thu, 2 Apr 2026 16:35:54 +0200 Subject: [PATCH] New Dependabot action (#731) Co-authored-by: UltralyticsAssistant --- actions/__init__.py | 1 + actions/dependabot.py | 298 ++++++++++++++++++++++++++++++++++++++++++ actions/scan_prs.py | 50 +++---- dependabot/action.yml | 69 ++++++++++ pyproject.toml | 1 + 5 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 actions/dependabot.py create mode 100644 dependabot/action.yml diff --git a/actions/__init__.py b/actions/__init__.py index e495474..5bfe184 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -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 diff --git a/actions/dependabot.py b/actions/dependabot.py new file mode 100644 index 0000000..b5f3eee --- /dev/null +++ b/actions/dependabot.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\s*-?\s*uses:\s*)(?P[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+(?:/[a-zA-Z0-9._/-]*)?)@(?P\S+?)(?P\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() diff --git a/actions/scan_prs.py b/actions/scan_prs.py index 0af1041..47ec60b 100644 --- a/actions/scan_prs.py +++ b/actions/scan_prs.py @@ -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: diff --git a/dependabot/action.yml b/dependabot/action.yml new file mode 100644 index 0000000..2bbe46c --- /dev/null +++ b/dependabot/action.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e5f407e..c28918f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]