Harden action network operations (#889)

Co-authored-by: Glenn Jocher <[email protected]>
This commit is contained in:
Paula Derrenger
2026-08-21 12:15:13 +02:00
committed by GitHub
co-authored by Glenn Jocher
parent fa396bf6ee
commit bcf6ad5223
4 changed files with 58 additions and 62 deletions
+20 -7
View File
@@ -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 \
+1 -1
View File
@@ -28,4 +28,4 @@
# ├── test_summarize_pr.py
# └── ...
__version__ = "0.3.15"
__version__ = "0.3.16"
+33 -53
View File
@@ -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}")
+4 -1
View File
@@ -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