diff --git a/action.yml b/action.yml index 0d5184d..4579e2f 100644 --- a/action.yml +++ b/action.yml @@ -246,12 +246,16 @@ runs: # language version and silently falls back to the pre-3.7 short style. - name: Setup Flutter SDK if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') - run: | - echo "::group::Setup Flutter SDK" - trap 'echo "::endgroup::"' EXIT - git clone --depth 1 --branch stable https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" - echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" - shell: bash + uses: ultralytics/actions/retry@main + with: + retries: 2 + timeout_minutes: 15 + run: | + echo "::group::Setup Flutter SDK" + trap 'echo "::endgroup::"' EXIT + rm -rf "$RUNNER_TEMP/flutter" + git clone --depth 1 --branch stable https://github.com/flutter/flutter.git "$RUNNER_TEMP/flutter" + echo "$RUNNER_TEMP/flutter/bin" >> "$GITHUB_PATH" - name: Run Dart Formatter if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize') @@ -423,7 +427,16 @@ runs: Darwin-x86_64) target=x86_64-apple-darwin ;; esac mkdir -p "$RUNNER_TEMP/lychee" - curl -sfL "https://github.com/lycheeverse/lychee/releases/latest/download/lychee-${target}.tar.gz" | tar -xz -C "$RUNNER_TEMP/lychee" + archive="lychee-${target}.tar.gz" + url="https://github.com/lycheeverse/lychee/releases/latest/download/$archive" + curl --retry 3 --retry-all-errors --retry-max-time 180 --connect-timeout 10 --max-time 60 -fsSL "$url" -o "$RUNNER_TEMP/lychee/$archive" + curl --retry 3 --retry-all-errors --retry-max-time 180 --connect-timeout 10 --max-time 60 -fsSL "$url.sha256" -o "$RUNNER_TEMP/lychee/$archive.sha256" + if command -v sha256sum >/dev/null; then + (cd "$RUNNER_TEMP/lychee" && sha256sum -c "$archive.sha256") + else + (cd "$RUNNER_TEMP/lychee" && shasum -a 256 -c "$archive.sha256") + fi + tar -xzf "$RUNNER_TEMP/lychee/$archive" -C "$RUNNER_TEMP/lychee" "$(find "$RUNNER_TEMP/lychee" -type f -name lychee)" \ --scheme https \ --timeout 60 \ diff --git a/actions/__init__.py b/actions/__init__.py index a3c0bfb..39178dd 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -28,4 +28,4 @@ # ├── test_summarize_pr.py # └── ... -__version__ = "0.3.15" +__version__ = "0.3.16" diff --git a/actions/dependabot.py b/actions/dependabot.py index 8947fef..021a80c 100644 --- a/actions/dependabot.py +++ b/actions/dependabot.py @@ -1,12 +1,10 @@ # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license """Update GitHub Actions versions across organization repositories with cached version resolution.""" -import json import os import re -import subprocess -import requests +from actions.utils import Action # Matches: `uses: owner/repo@ref` or `uses: owner/repo/path@ref` with optional `# comment` USES_PATTERN = re.compile( @@ -55,14 +53,13 @@ def ref_version(ref, comment=""): return ref -def get_latest_release(action, token, cache): +def get_latest_release(action, client, 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) + r = client.get(f"https://api.github.com/repos/{repo}/releases/latest") if r.status_code != 200: print(f" Could not resolve latest version for {repo}") return None @@ -73,11 +70,11 @@ def get_latest_release(action, token, cache): # 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) + r2 = client.get(f"https://api.github.com/repos/{repo}/git/ref/tags/{tag}") if r2.status_code == 200: obj = r2.json().get("object", {}) if obj.get("type") == "tag": - r3 = requests.get(obj["url"], headers=headers) + r3 = client.get(obj["url"]) sha = r3.json().get("object", {}).get("sha") if r3.status_code == 200 else None else: sha = obj.get("sha") @@ -87,7 +84,7 @@ def get_latest_release(action, token, cache): major_tag = f"{major_match.group(1)}{major_match.group(2)}" if major_match else None has_major_tag = False if major_tag and major_tag != tag: - r3 = requests.get(f"https://api.github.com/repos/{repo}/git/ref/tags/{major_tag}", headers=headers) + r3 = client.get(f"https://api.github.com/repos/{repo}/git/ref/tags/{major_tag}") has_major_tag = r3.status_code == 200 cache[repo] = {"tag": tag, "sha": sha, "major_tag": major_tag if major_tag == tag or has_major_tag else None} @@ -128,29 +125,28 @@ def compute_update(current_ref, comment, latest): return None -def get_workflow_files(org, repo, token): +def get_workflow_files(org, repo, client): """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) + r = client.get(f"https://api.github.com/repos/{org}/{repo}/contents/.github/workflows") 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) + r = client.get(f"https://api.github.com/repos/{org}/{repo}/contents/{name}") if r.status_code == 200: files.append(name) return files -def get_file_content(org, repo, path, token): +def get_file_content(org, repo, path, client): """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) + headers = {**client.headers, "Accept": "application/vnd.github.v3.raw"} + r = client.get(f"https://api.github.com/repos/{org}/{repo}/contents/{path}", headers=headers) return r.text if r.status_code == 200 else None @@ -173,37 +169,29 @@ def title_exists(open_titles, action, new_ref, new_comment, old_versions): return any(pattern.fullmatch(title) for title in open_titles) -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, - check=False, - ) - if result.returncode != 0: - return set() - return {pr["title"] for pr in json.loads(result.stdout)} +def get_open_pr_titles(org, repo, client): + """Get titles of all open PRs in a repo.""" + prs = client.paginate(f"https://api.github.com/repos/{org}/{repo}/pulls", params={"state": "open"}, hard=True) + return {pr["title"] for pr in prs} -def create_pr(org, repo, title, file_updates, token): +def create_pr(org, repo, title, file_updates, client): """Create a PR with a single commit updating one or more files using the Git Data API.""" - headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"} api = f"https://api.github.com/repos/{org}/{repo}" # Get default branch and its HEAD commit SHA - r = requests.get(api, headers=headers) + r = client.get(api) if r.status_code != 200: return None default_branch = r.json()["default_branch"] - r = requests.get(f"{api}/git/ref/heads/{default_branch}", headers=headers) + r = client.get(f"{api}/git/ref/heads/{default_branch}") if r.status_code != 200: return None base_sha = r.json()["object"]["sha"] # Get base tree SHA - r = requests.get(f"{api}/git/commits/{base_sha}", headers=headers) + r = client.get(f"{api}/git/commits/{base_sha}") if r.status_code != 200: return None base_tree_sha = r.json()["tree"]["sha"] @@ -211,23 +199,22 @@ def create_pr(org, repo, title, file_updates, token): # Create blobs for each file and build tree entries tree_entries = [] for path, content in file_updates: - r = requests.post(f"{api}/git/blobs", headers=headers, json={"content": content, "encoding": "utf-8"}) + r = client.post(f"{api}/git/blobs", json={"content": content, "encoding": "utf-8"}) if r.status_code not in (200, 201): print(f" Failed to create blob for {path}, aborting PR") return None tree_entries.append({"path": path, "mode": "100644", "type": "blob", "sha": r.json()["sha"]}) # Create tree - r = requests.post(f"{api}/git/trees", headers=headers, json={"base_tree": base_tree_sha, "tree": tree_entries}) + r = client.post(f"{api}/git/trees", json={"base_tree": base_tree_sha, "tree": tree_entries}) if r.status_code not in (200, 201): print(f" Failed to create tree: {r.json().get('message', '')}") return None tree_sha = r.json()["sha"] # Create single commit - r = requests.post( + r = client.post( f"{api}/git/commits", - headers=headers, json={"message": title, "tree": tree_sha, "parents": [base_sha]}, ) if r.status_code not in (200, 201): @@ -238,15 +225,14 @@ def create_pr(org, repo, title, file_updates, token): # Create branch pointing to the commit slug = re.sub(r"[^a-zA-Z0-9]+", "-", title).strip("-").lower()[:60] branch = f"dependabot/github_actions/{slug}" - r = requests.post(f"{api}/git/refs", headers=headers, json={"ref": f"refs/heads/{branch}", "sha": commit_sha}) + r = client.post(f"{api}/git/refs", json={"ref": f"refs/heads/{branch}", "sha": commit_sha}) if r.status_code not in (200, 201): print(f" Failed to create branch: {r.json().get('message', '')}") return None # Create PR - r = requests.post( + r = client.post( f"{api}/pulls", - headers=headers, json={ "title": title, "body": ( @@ -277,6 +263,7 @@ def run(): if not token: print("Error: GH_TOKEN or GITHUB_TOKEN required") return + client = Action(token=token, event_data={}, verbose=False) # 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"} @@ -284,15 +271,8 @@ def run(): 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 - ) + org_repos = client.paginate(f"https://api.github.com/orgs/{org}/repos", params={"type": "all"}, hard=True) + repos = sorted(r["name"] for r in org_repos if not r["archived"] and r["visibility"].lower() in visibility) print(f"Found {len(repos)} active repos\n") cache = {} @@ -303,7 +283,7 @@ def run(): for repo_name in repos: print(f"📦 {org}/{repo_name}") - workflow_files = get_workflow_files(org, repo_name, token) + workflow_files = get_workflow_files(org, repo_name, client) if not workflow_files: print(" No workflow files found") continue @@ -314,7 +294,7 @@ def run(): file_cache = {} # path -> original content for path in workflow_files: - content = get_file_content(org, repo_name, path, token) + content = get_file_content(org, repo_name, path, client) if not content: continue file_cache[path] = content @@ -327,7 +307,7 @@ def run(): if is_branch(ref): continue - latest = get_latest_release(action, token, cache) + latest = get_latest_release(action, client, cache) update = compute_update(ref, comment, latest) if update is None: continue @@ -351,7 +331,7 @@ def run(): continue # Phase 2: create one PR per action update - open_titles = get_open_pr_titles(org, repo_name) + open_titles = get_open_pr_titles(org, repo_name, client) for (action_repo, new_ref), info in updates.items(): title = make_pr_title(action_repo, new_ref, info["new_comment"], info["old_versions"]) @@ -371,7 +351,7 @@ def run(): 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) + pr_url = create_pr(org, repo_name, title, list(file_updates.items()), client) if pr_url: print(f" ✅ Created PR: {pr_url}") summary.append(f"- ✅ [{org}/{repo_name}]({pr_url}): {title}") diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index f4f95df..15d6c8c 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -121,7 +121,9 @@ class Action: """Initializes a GitHub Actions API handler with token and event data for processing events.""" self.token = token or os.getenv("GITHUB_TOKEN") self.event_name = event_name or os.getenv("GITHUB_EVENT_NAME") - self.event_data = event_data or self._load_event_data(os.getenv("GITHUB_EVENT_PATH")) + self.event_data = ( + event_data if event_data is not None else self._load_event_data(os.getenv("GITHUB_EVENT_PATH")) + ) self.pr = self.event_data.get("pull_request", {}) self.repository = self.event_data.get("repository", {}).get("full_name") self.owner, self.repo_name = self.repository.split("/") if self.repository else (None, None) @@ -152,6 +154,7 @@ class Action: def _request(self, method: str, url: str, headers=None, expected_status=None, hard=False, **kwargs): """Unified request handler with error checking.""" + kwargs.setdefault("timeout", (10, 60)) r = getattr(self.session, method)(url, headers=headers or self.headers, **kwargs) expected = expected_status or self._default_status[method] status_expected = r.status_code in expected