diff --git a/actions/dependabot.py b/actions/dependabot.py index b5f3eee..ec878dd 100644 --- a/actions/dependabot.py +++ b/actions/dependabot.py @@ -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() diff --git a/dependabot/README.md b/dependabot/README.md new file mode 100644 index 0000000..41d9edd --- /dev/null +++ b/dependabot/README.md @@ -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.