Merge branch 'main' into claude-dispatcher-workflow
This commit is contained in:
@@ -4,22 +4,17 @@ This file provides guidance to AI coding agents (Claude Code, etc.) when working
|
||||
|
||||
## Core Principles (CRITICAL)
|
||||
|
||||
Respecting these principles is critical for every PR.
|
||||
**Less is more. The simplest solution is the best solution.** The action hierarchy for every change: **Delete > Replace > Add**.
|
||||
|
||||
**Less is more. The simplest solution is the best solution.**
|
||||
1. **Solve at the owner**: Put behavior in the code path that owns or observes it. For fixes, never guard a symptom with a staleness check, initialization flag, skip-first-call branch, or `try/except` around broken logic; relocate the trigger and delete the wrong path. For features, extend the existing owner rather than creating a parallel abstraction.
|
||||
2. **Search and reuse first**: Search the whole repository before creating anything new — a module, helper, utility, composite action, or workflow. Reuse or adapt what exists, consolidate in-scope duplication in the shared owner, and delete duplicate paths. Three similar lines beat a helper nobody else calls.
|
||||
3. **Delete and modify existing code before creating new code**: Bugfixes are net-negative by default unless deletion and relocation are demonstrably impossible. A new file must first prove it cannot fit cleanly in an existing owner.
|
||||
4. **Keep scope minimal**: Implement only the simplest complete solution. Avoid impossible-state handling, speculative flags, compatibility shims, policy scaffolding, and unrelated cleanup. Tests are out of scope by default — rely on existing coverage and focused validation; only an uncovered, high-risk regression path justifies minimal new test code.
|
||||
5. **Ship zero-regression, production-ready changes**: Understand what you remove instead of retaining broken code as insurance. Remove unused imports, functions, types, files, and commented-out code; run relevant cleanup checks; and thoroughly debug and validate the changed owner. Do not break existing features or workflows unless the PR intentionally removes them with evidence.
|
||||
|
||||
The action hierarchy for every change: **Delete > Replace > Add**. The best code change is a deletion. The second best is modifying what exists. Adding new code is the last resort.
|
||||
**Review gate:** for every addition, the reviewer decides whether deleting or changing existing code would have fixed the problem instead — if it would, that is a blocking finding. A missing or thin PR description is never itself a finding.
|
||||
|
||||
1. **Minimal**: The simplest solution that works. Do not over-engineer, over-abstract, or add code just in case. Three similar lines beat a premature abstraction. Avoid error handling for impossible states, feature flags, compatibility shims, or policy scaffolding unless they are truly required.
|
||||
2. **Solve at the source**: Do not hack fixes. Solve problems at their root. If something is broken, fix or remove the broken thing. Never patch over a broken abstraction, add workarounds, or add synchronization code for state that should not be duplicated.
|
||||
3. **Delete ruthlessly**: When replacing code, delete what it replaced. Remove unused imports, functions, types, files, and commented-out code. Git preserves history. Run the repo's relevant dead-code or cleanup check when available.
|
||||
4. **Replace > Add**: Modify existing code over adding new code. Edit existing files, extend existing components or functions with minimal parameters, and reuse existing utilities. If creating a new file, first prove it cannot fit cleanly in an existing file.
|
||||
5. **Check existing**: Search the entire repo before creating anything new. If a feature, component, helper, responder, workflow, or utility already solves a similar problem, reuse or adapt it and delete the duplicate path.
|
||||
6. **Deduplicate**: Do not duplicate existing code when updating the repo. Consolidate or refactor duplicates you find when it is in scope and low risk.
|
||||
7. **Zero Regression**: Do not break existing features or workflows unless the PR intentionally removes them with evidence.
|
||||
8. **Production ready**: All changes must be thoroughly debugged, validated, and production ready.
|
||||
|
||||
**When fixing bugs, ask: "What can I delete?" before "What can I replace?" before "What should I add?"**
|
||||
NEVER push to `main`. NEVER force push. Always start work in a new git worktree (`git worktree add`) on a feature branch and open a PR — never edit the primary checkout directly, it may hold in-flight work.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
@@ -27,7 +22,7 @@ After opening a PR:
|
||||
|
||||
1. Wait for the automated PR review and auto-format commit from Ultralytics Actions (`format.yml`), then pull and address every finding.
|
||||
2. Launch an independent adversarial review agent with cold context (just the PR diff and this file) to hunt for bugs, regressions, and Core Principles violations. Fix, push, and repeat with a fresh agent until one reports LGTM.
|
||||
3. Never fight other commits: Ultralytics Actions pushes auto-format and header commits, and multiple users may work on the same PR. `git pull --rebase` before pushing; never force-push, reset, or revert commits you did not author.
|
||||
3. Never fight other commits: Ultralytics Actions pushes auto-format and header commits, and multiple users may work on the same PR. `git pull --rebase` before pushing; never reset or revert commits you did not author.
|
||||
4. After the PR merges, clean up: remove local worktrees and branches for it, then `git checkout main && git pull`.
|
||||
|
||||
## Commands
|
||||
|
||||
+11
-11
@@ -113,17 +113,17 @@ runs:
|
||||
env:
|
||||
INPUTS_SPELLING: ${{ inputs.spelling }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
echo "::group::Install Dependencies"
|
||||
trap 'echo "::endgroup::"' EXIT
|
||||
# Install from current git branch if testing in ultralytics/actions repo, otherwise from GitHub main branch
|
||||
# Source tarballs install ~2x faster than git+ URLs, which clone the repository first. Test the current commit
|
||||
# in the ultralytics/actions repo, otherwise install main.
|
||||
if [ "$GITHUB_REPOSITORY" = "ultralytics/actions" ]; then
|
||||
echo "Installing from git branch: $GITHUB_REF"
|
||||
packages="git+https://github.com/ultralytics/actions@${GITHUB_REF#refs/heads/}"
|
||||
echo "Installing from commit: $GITHUB_SHA"
|
||||
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
|
||||
else
|
||||
# packages="ultralytics-actions"
|
||||
packages="git+https://github.com/ultralytics/actions@main"
|
||||
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
|
||||
fi
|
||||
|
||||
if [ "$INPUTS_SPELLING" = "true" ]; then
|
||||
@@ -216,18 +216,18 @@ runs:
|
||||
run: |
|
||||
echo "::group::Run Prettier"
|
||||
trap 'echo "::endgroup::"' EXIT
|
||||
npm install -g [email protected] prettier-plugin-sh
|
||||
npm install -g [email protected]
|
||||
curl -fsSL "https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" -o "$(npm prefix -g)/bin/shfmt"
|
||||
chmod +x "$(npm prefix -g)/bin/shfmt"
|
||||
ultralytics-actions-update-markdown-code-blocks
|
||||
npx prettier --write --list-different --print-width 120 "**/*.{js,jsx,ts,tsx,css,less,scss,json,yml,yaml,html,vue,svelte}" '!**/*lock.{json,yaml,yml}' '!**/*.lock' '!**/model.json' '!**/*.min.js' '!**/*.min.css'
|
||||
# Bash file format
|
||||
if find . -name "*.sh" -type f | grep -q .; then
|
||||
npx prettier --write --list-different --print-width 120 --plugin=$(npm root -g)/prettier-plugin-sh/lib/index.cjs "**/*.sh"
|
||||
fi
|
||||
# Handle Markdown separately
|
||||
find . -name "*.md" -type f ! -path "*/docs/*" -exec npx prettier --write --list-different --print-width 120 {} +
|
||||
if [ -d "./docs" ]; then
|
||||
find ./docs -name "*.md" -type f ! -path "*/reference/*" -exec npx prettier --tab-width 4 --print-width 120 --write --list-different {} +
|
||||
fi
|
||||
# Bash last: shfmt exits non-zero when a script fails to parse, which would otherwise skip the formatting above
|
||||
find . -name "*.sh" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -exec shfmt -i 2 -sr -bn -ci -l -w {} +
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@
|
||||
# ├── test_summarize_pr.py
|
||||
# └── ...
|
||||
|
||||
__version__ = "0.2.37"
|
||||
__version__ = "0.2.40"
|
||||
|
||||
@@ -19,17 +19,18 @@ RUFF_CHECK = [
|
||||
RUFF_FORMAT = ["ruff", "format", "--line-length=120", "."]
|
||||
DOCSTRINGS = ["ultralytics-actions-format-python-docstrings", "."]
|
||||
PRETTIER = """
|
||||
npm install -g [email protected] prettier-plugin-sh
|
||||
npm install -g [email protected]
|
||||
curl -fsSL "https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" -o "$(npm prefix -g)/bin/shfmt"
|
||||
chmod +x "$(npm prefix -g)/bin/shfmt"
|
||||
ultralytics-actions-update-markdown-code-blocks
|
||||
npx prettier --write --list-different --print-width 120 "**/*.{js,jsx,ts,tsx,css,less,scss,json,yml,yaml,html,vue,svelte}" '!**/*lock.{json,yaml,yml}' '!**/*.lock' '!**/model.json' '!**/*.min.js' '!**/*.min.css'
|
||||
if find . -name "*.sh" -type f | grep -q .; then
|
||||
npx prettier --write --list-different --print-width 120 --plugin=$(npm root -g)/prettier-plugin-sh/lib/index.cjs "**/*.sh"
|
||||
fi
|
||||
# Handle Markdown separately
|
||||
find . -name "*.md" -type f ! -path "*/docs/*" -exec npx prettier --write --list-different --print-width 120 {} +
|
||||
if [ -d "./docs" ]; then
|
||||
find ./docs -name "*.md" -type f ! -path "*/reference/*" -exec npx prettier --tab-width 4 --print-width 120 --write --list-different {} +
|
||||
fi
|
||||
# Bash last: shfmt exits non-zero when a script fails to parse, which would otherwise skip the formatting above
|
||||
find . -name "*.sh" -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -exec shfmt -i 2 -sr -bn -ci -l -w {} +
|
||||
"""
|
||||
CODESPELL = [
|
||||
"codespell",
|
||||
|
||||
@@ -120,13 +120,9 @@ def update_file(file_path, prefix, block_start, block_end, base_header):
|
||||
|
||||
# Check for special first line
|
||||
special_line_index = -1
|
||||
first_line = lines[0].lstrip("\ufeff") if lines else ""
|
||||
first_line = lines[0].lstrip("\ufeff").lower() if lines else ""
|
||||
encoding_cookie = r"^[ \t\f]*#.*?coding[:=][ \t]*[-_.a-zA-Z0-9]+"
|
||||
if lines and (
|
||||
first_line.startswith(("#!", "<?xml"))
|
||||
or first_line.lower().startswith("<!doctype")
|
||||
or re.match(encoding_cookie, first_line)
|
||||
):
|
||||
if first_line.startswith(("#!", "<?xml", "<!doctype")) or re.match(encoding_cookie, first_line):
|
||||
special_line_index = 0
|
||||
prefix_lines.append(lines[0])
|
||||
if first_line.startswith("#!") and len(lines) > 1 and re.match(encoding_cookie, lines[1]):
|
||||
@@ -136,14 +132,12 @@ def update_file(file_path, prefix, block_start, block_end, base_header):
|
||||
start_idx = special_line_index + 1 if special_line_index >= 0 else 0
|
||||
end_idx = min(start_idx + 5, len(lines)) # Look in first few lines
|
||||
|
||||
# An existing header must be the first non-empty line and use the file's comment syntax.
|
||||
candidate_index = next((i for i in range(start_idx, end_idx) if lines[i].strip()), -1)
|
||||
candidate = lines[candidate_index].strip() if candidate_index >= 0 else ""
|
||||
comment_start = (prefix or block_start or "# ").strip()
|
||||
known_header = candidate.startswith(comment_start) and re.search(
|
||||
r"AGPL-3\.0 License|CONFIDENTIAL: Unauthorized use|©\s*2014[–-]\d{4}\s+Ultralytics Inc\.|"
|
||||
r"Ultralytics Inc\..*Copyright ©\s*2014[–-]\d{4}\s*-\s*CONFIDENTIAL\s*-",
|
||||
candidate,
|
||||
header = candidate[len(comment_start) :].strip() if candidate.startswith(comment_start) else ""
|
||||
known_header = header.startswith(("Ultralytics 🚀 AGPL-3.0 License", "Ultralytics Inc. 🚀 Copyright")) or (
|
||||
header.startswith("© 2014-") and " Ultralytics Inc." in header
|
||||
)
|
||||
header_index = candidate_index if candidate == formatted_header.strip() or known_header else -1
|
||||
|
||||
|
||||
@@ -96,26 +96,25 @@ def format_code_with_ruff(temp_dir):
|
||||
print(f"ERROR running Python docstring formatter ❌ {e}")
|
||||
|
||||
|
||||
def format_bash_with_prettier(temp_dir):
|
||||
"""Formats bash script files in the specified directory using prettier."""
|
||||
def format_bash_with_shfmt(temp_dir):
|
||||
"""Formats bash script files in the specified directory using shfmt."""
|
||||
if not next(Path(temp_dir).rglob("*.sh"), None):
|
||||
return
|
||||
|
||||
try:
|
||||
# Run prettier with explicit config path
|
||||
# Flags reproduce prettier-plugin-sh output: 2-space indent, spaced redirects, binary ops and cases indented
|
||||
result = subprocess.run(
|
||||
"npx prettier --write --print-width=120 --plugin=$(npm root -g)/prettier-plugin-sh/lib/index.cjs ./**/*.sh",
|
||||
shell=True, # must use shell=True to expand internal $(cmd)
|
||||
["shfmt", "-i", "2", "-sr", "-bn", "-ci", "-w", str(temp_dir)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR running prettier-plugin-sh ❌ {result.stderr}")
|
||||
print(f"ERROR running shfmt ❌ {result.stderr}")
|
||||
else:
|
||||
print("Completed bash formatting ✅")
|
||||
except Exception as e:
|
||||
print(f"ERROR running prettier-plugin-sh ❌ {e}")
|
||||
print(f"ERROR running shfmt ❌ {e}")
|
||||
|
||||
|
||||
def process_markdown_string(
|
||||
@@ -141,7 +140,7 @@ def process_markdown_string(
|
||||
if python_files_exist:
|
||||
format_code_with_ruff(temp_dir)
|
||||
if bash_files_exist:
|
||||
format_bash_with_prettier(temp_dir)
|
||||
format_bash_with_shfmt(temp_dir)
|
||||
|
||||
update_markdown_file(temp_md, markdown_snapshot, temp_files)
|
||||
formatted_markdown = temp_md.read_text(encoding="utf-8")
|
||||
@@ -244,7 +243,7 @@ def main(root_dir=None, process_python=True, process_bash=True, verbose=False):
|
||||
if process_python:
|
||||
format_code_with_ruff(temp_dir) # Format Python files
|
||||
if process_bash:
|
||||
format_bash_with_prettier(temp_dir) # Format Bash files
|
||||
format_bash_with_shfmt(temp_dir) # Format Bash files
|
||||
|
||||
# Update Markdown files with formatted code blocks
|
||||
for markdown_file, markdown_content, temp_files in all_temp_files:
|
||||
|
||||
@@ -182,9 +182,9 @@ REDIRECT_END_IGNORE_LIST = frozenset(
|
||||
}
|
||||
)
|
||||
URL_PATTERN = re.compile(
|
||||
r"\[([^]]+)]\(([^)]+)\)" # Matches Markdown links [text](url)
|
||||
r"\[(?P<md_text>[^]]+)]\((?P<md_url>[^)]+)\)" # Matches Markdown links [text](url)
|
||||
r"|"
|
||||
r"(" # Start capturing group for plaintext URLs
|
||||
r"(?P<plain_url>" # Start capturing group for plaintext URLs
|
||||
r"(?:https?://)?" # Optional http:// or https://
|
||||
r"(?:www\.)?" # Optional www.
|
||||
r"(?:[\w.-]+)?" # Optional domain name and subdomains
|
||||
@@ -292,8 +292,14 @@ def brave_search(query, api_key, count=5):
|
||||
if len(query) > 400:
|
||||
print(f"WARNING ⚠️ Brave search query length {len(query)} exceed limit of 400 characters, truncating.")
|
||||
url = f"https://api.search.brave.com/res/v1/web/search?q={parse.quote(query.strip()[:400])}&count={count}"
|
||||
response = requests.get(url, headers={"X-Subscription-Token": api_key, "Accept": "application/json"})
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
try:
|
||||
response = requests.get(
|
||||
url, headers={"X-Subscription-Token": api_key, "Accept": "application/json"}, timeout=10
|
||||
)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
except Exception as e: # a search outage must never block the caller from returning its text
|
||||
print(f"WARNING ⚠️ Brave search failed: {e}")
|
||||
return []
|
||||
results = data.get("web", {}).get("results", []) if data else []
|
||||
return [result.get("url") for result in results if result.get("url")]
|
||||
|
||||
@@ -351,10 +357,10 @@ def is_url(url, session=None, check=True, max_attempts=3, timeout=3, return_url=
|
||||
def check_links_in_string(text, verbose=True, return_bad=False, replace=False):
|
||||
"""Process text, find URLs, check for 404s, and handle replacements with redirects or Brave search."""
|
||||
urls = []
|
||||
for md_text, md_url, plain_url in URL_PATTERN.findall(text):
|
||||
url = md_url or plain_url
|
||||
for match in URL_PATTERN.finditer(text):
|
||||
url = match["md_url"] or match["plain_url"]
|
||||
if url and parse.urlparse(url).scheme:
|
||||
urls.append((md_text, clean_url(url)))
|
||||
urls.append((match["md_text"] or "", clean_url(url)))
|
||||
|
||||
with requests.Session() as session, ThreadPoolExecutor(max_workers=64) as executor:
|
||||
session.headers.update(REQUESTS_HEADERS)
|
||||
@@ -363,35 +369,47 @@ def check_links_in_string(text, verbose=True, return_bad=False, replace=False):
|
||||
bad_urls = [url for (title, url), (valid, redirect) in zip(urls, results) if not valid]
|
||||
|
||||
if replace:
|
||||
replacements = {}
|
||||
modified_text = text
|
||||
replacements, searched = {}, set()
|
||||
|
||||
# Process all URLs for replacements
|
||||
brave_api_key = os.getenv("BRAVE_API_KEY")
|
||||
for (title, url), (valid, redirect) in zip(urls, results):
|
||||
# Handle invalid URLs with Brave search
|
||||
if not valid and brave_api_key:
|
||||
query = f"{(redirect or url)[:200]} {title[:199]}"
|
||||
if search_urls := brave_search(query, brave_api_key, count=3):
|
||||
best_url = next(
|
||||
(alt_url for alt_url in search_urls if is_url(alt_url, session)),
|
||||
search_urls[0],
|
||||
)
|
||||
if url != best_url:
|
||||
# Handle invalid URLs with Brave search. Two queries, not two attempts: the dead URL biases the
|
||||
# first toward the site root, so the second drops it and searches the link text on its domain.
|
||||
if not valid:
|
||||
if url in searched: # search once per URL, however many times it occurs
|
||||
continue
|
||||
searched.add(url)
|
||||
for query in (
|
||||
f"{(redirect or url)[:200]} {title[:199]}",
|
||||
f"{title[:199]} {parse.urlparse(url).netloc}",
|
||||
):
|
||||
search_urls = brave_search(query, brave_api_key, count=3) or []
|
||||
if best_url := next((u for u in search_urls if u != url and is_url(u, session)), None):
|
||||
replacements[url] = best_url
|
||||
modified_text = modified_text.replace(url, best_url)
|
||||
break
|
||||
# Handle redirects for valid URLs
|
||||
elif valid and redirect and redirect != url:
|
||||
elif redirect and redirect != url:
|
||||
replacements[url] = redirect
|
||||
modified_text = modified_text.replace(url, redirect)
|
||||
|
||||
if verbose and replacements:
|
||||
print(
|
||||
f"WARNING ⚠️ replaced {len(replacements)} links:\n"
|
||||
+ "\n".join(f" {k}: {v}" for k, v in replacements.items())
|
||||
)
|
||||
if replacements:
|
||||
return (True, bad_urls, modified_text) if return_bad else modified_text
|
||||
|
||||
def replace_link(match):
|
||||
"""Swap a matched URL for its replacement, leaving the surrounding link syntax untouched."""
|
||||
group = "md_url" if match["md_url"] else "plain_url"
|
||||
raw_url = match[group]
|
||||
if not (new_url := replacements.get(clean_url(raw_url))):
|
||||
return match[0]
|
||||
start, end = (i - match.start() for i in match.span(group))
|
||||
suffix = raw_url[len(raw_url.rstrip(".,:;!?`\\")) :] # trailing punctuation clean_url() dropped
|
||||
return f"{match[0][:start]}{new_url}{suffix}{match[0][end:]}"
|
||||
|
||||
text = URL_PATTERN.sub(replace_link, text)
|
||||
bad_urls = [url for url in bad_urls if url not in replacements] # unfixable links stay reported
|
||||
|
||||
passing = not bad_urls
|
||||
if verbose and not passing:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 🧹 Disk Space Cleanup Action
|
||||
|
||||
Cleans up disk space on Ubuntu GitHub Actions runners by removing unnecessary tool caches and swap space. Frees up ~19GB total space.
|
||||
Cleans up disk space on Ubuntu GitHub Actions runners by removing unnecessary tool caches, Docker images, and swap space.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
@@ -10,6 +10,9 @@ Cleans up disk space on Ubuntu GitHub Actions runners by removing unnecessary to
|
||||
|
||||
Add as early step in jobs requiring disk space:
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Run cleanup before pulling or building Docker images because it removes all images unused by containers.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: ultralytics/actions/cleanup-disk@main
|
||||
@@ -57,7 +60,8 @@ steps:
|
||||
## 🗑️ What Gets Cleaned
|
||||
|
||||
- `/opt/hostedtoolcache` - Tool cache (~15GB)
|
||||
- `/swapfile` - Swap space (~4GB)
|
||||
- Unused Docker images
|
||||
- `/swapfile` or `/mnt/swapfile` - Swap space (~4GB)
|
||||
|
||||
## 💡 When to Use
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
name: "Disk Space Cleanup"
|
||||
author: "Ultralytics"
|
||||
description: "Cleans up disk space by removing unnecessary tool caches and swap space"
|
||||
description: "Cleans up disk space by removing unnecessary tool caches, Docker images, and swap space"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -13,21 +13,24 @@ runs:
|
||||
df -h /
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Remove Tool Cache and Swap"
|
||||
echo "::group::Remove Tool Cache, Docker Images, and Swap"
|
||||
toolcache_pid=""
|
||||
if [ -e /opt/hostedtoolcache ]; then
|
||||
# Remove tool cache to free up ~15GB of space per https://github.com/ultralytics/ultralytics/pull/15848
|
||||
rm -rf /opt/hostedtoolcache &
|
||||
toolcache_pid=$!
|
||||
fi
|
||||
docker image prune --all --force &
|
||||
docker_pid=$!
|
||||
# Remove swap space to free up ~4GB
|
||||
if [ -f /swapfile ]; then
|
||||
if [ -f /swapfile ] || [ -f /mnt/swapfile ]; then
|
||||
sudo swapoff -a
|
||||
sudo rm -f /swapfile
|
||||
sudo rm -f /swapfile /mnt/swapfile
|
||||
fi
|
||||
if [ -n "$toolcache_pid" ]; then
|
||||
wait "$toolcache_pid" || true
|
||||
fi
|
||||
wait "$docker_pid" || true
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Disk Space After Cleanup"
|
||||
|
||||
@@ -39,9 +39,9 @@ runs:
|
||||
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}"
|
||||
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
|
||||
else
|
||||
packages="git+https://github.com/ultralytics/actions@main"
|
||||
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
|
||||
fi
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
uv pip install --system --break-system-packages $packages
|
||||
|
||||
@@ -55,9 +55,9 @@ runs:
|
||||
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}"
|
||||
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
|
||||
else
|
||||
packages="git+https://github.com/ultralytics/actions@main"
|
||||
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
|
||||
fi
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
uv pip install --system --break-system-packages $packages
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||
"""Tests for the file headers update functionality."""
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from actions.update_file_headers import COMMENT_MAP, IGNORE_PATHS, main, update_file
|
||||
|
||||
|
||||
def test_update_file_python():
|
||||
"""Test updating Python file headers."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
# Create a test Python file
|
||||
test_file = Path(tmp_dir) / "test.py"
|
||||
test_file.write_text("print('Hello World')\n")
|
||||
|
||||
# Update file
|
||||
result = update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header")
|
||||
|
||||
# Check results
|
||||
assert result is True
|
||||
content = test_file.read_text()
|
||||
assert content.startswith("# Ultralytics 🚀 Test Header\n\n")
|
||||
assert "print('Hello World')" in content
|
||||
|
||||
|
||||
def test_update_file_cpp():
|
||||
"""Test updating C++ file headers."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
# Create a test C++ file
|
||||
test_file = Path(tmp_dir) / "test.cpp"
|
||||
test_file.write_text("#include <iostream>\n\nint main() {\n return 0;\n}\n")
|
||||
|
||||
# Update file
|
||||
result = update_file(test_file, "// ", "/* ", " */", "Ultralytics 🚀 Test Header")
|
||||
|
||||
# Check results
|
||||
assert result is True
|
||||
content = test_file.read_text()
|
||||
assert content.startswith("// Ultralytics 🚀 Test Header\n\n")
|
||||
assert "#include <iostream>" in content
|
||||
|
||||
|
||||
def test_update_file_with_existing_header():
|
||||
"""Test updating file with existing header."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
# Create a test file with existing header
|
||||
test_file = Path(tmp_dir) / "test.py"
|
||||
test_file.write_text("# Ultralytics 🚀 AGPL-3.0 License\n\ndef main():\n pass\n")
|
||||
|
||||
# Update file
|
||||
result = update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header")
|
||||
|
||||
# Check results
|
||||
assert result is True
|
||||
content = test_file.read_text()
|
||||
assert content.startswith("# Ultralytics 🚀 Test Header\n\n")
|
||||
assert "def main():" in content
|
||||
|
||||
|
||||
def test_update_file_with_shebang():
|
||||
"""Test updating file with shebang."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
# Create a test file with shebang
|
||||
test_file = Path(tmp_dir) / "test.py"
|
||||
test_file.write_text("#!/usr/bin/env python3\n\nprint('Hello World')\n")
|
||||
|
||||
# Update file
|
||||
result = update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header")
|
||||
|
||||
# Check results
|
||||
assert result is True
|
||||
content = test_file.read_text()
|
||||
assert content.startswith("#!/usr/bin/env python3\n# Ultralytics 🚀 Test Header\n\n")
|
||||
assert "print('Hello World')" in content
|
||||
|
||||
|
||||
def test_update_file_preserves_early_ultralytics_source():
|
||||
"""Test that Ultralytics references in source code are not mistaken for headers."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
docstring_file = tmp_path / "api.py"
|
||||
docstring_file.write_text('"""Ultralytics Platform API client."""\n\nVALUE = 1\n')
|
||||
import_file = tmp_path / "__init__.py"
|
||||
import_file.write_text("from ultralytics import YOLO\n")
|
||||
|
||||
for test_file, spacing in ((docstring_file, ""), (import_file, "\n")):
|
||||
original = test_file.read_text()
|
||||
assert update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header") is True
|
||||
assert test_file.read_text() == f"# Ultralytics 🚀 Test Header\n{spacing}{original}"
|
||||
|
||||
|
||||
def test_update_file_with_lowercase_doctype():
|
||||
"""Test that Prettier's lowercase HTML doctype remains first."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
test_file = Path(tmp_dir) / "index.html"
|
||||
test_file.write_text("<!doctype html>\n<html></html>\n")
|
||||
|
||||
assert update_file(test_file, None, "<!-- ", " -->", "Ultralytics 🚀 Test Header") is True
|
||||
assert test_file.read_text().startswith("<!doctype html>\n<!-- Ultralytics 🚀 Test Header -->\n\n")
|
||||
|
||||
|
||||
def test_update_file_with_bom_xml_declaration():
|
||||
"""Test that a BOM-prefixed XML declaration remains first."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
test_file = Path(tmp_dir) / "layout.xml"
|
||||
test_file.write_text('\ufeff<?xml version="1.0" encoding="utf-8"?>\n<root />\n')
|
||||
|
||||
assert update_file(test_file, None, "<!-- ", " -->", "Ultralytics 🚀 Test Header") is True
|
||||
assert test_file.read_text().startswith(
|
||||
'\ufeff<?xml version="1.0" encoding="utf-8"?>\n<!-- Ultralytics 🚀 Test Header -->\n\n'
|
||||
)
|
||||
|
||||
|
||||
def test_update_file_with_shebang_and_encoding_cookie():
|
||||
"""Test that a Python encoding cookie remains on line two and the header stays idempotent."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
test_file = Path(tmp_dir) / "script.py"
|
||||
test_file.write_text(
|
||||
"#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n# Ultralytics 🚀 AGPL-3.0 License\n\nprint('hello')\n"
|
||||
)
|
||||
|
||||
assert update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header") is True
|
||||
assert test_file.read_text().startswith(
|
||||
"#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n# Ultralytics 🚀 Test Header\n\n"
|
||||
)
|
||||
assert update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header") is False
|
||||
|
||||
|
||||
def test_update_file_replaces_legacy_private_header():
|
||||
"""Test that the previously emitted private header is replaced rather than duplicated."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
test_file = Path(tmp_dir) / "app.py"
|
||||
test_file.write_text(
|
||||
"# Ultralytics Inc. 🚀 Copyright © 2014-2025 - CONFIDENTIAL - "
|
||||
"https://ultralytics.com - All Rights Reserved\n\n"
|
||||
"print('hello')\n"
|
||||
)
|
||||
|
||||
assert update_file(test_file, "# ", None, None, "© 2014-2026 Ultralytics Inc. CONFIDENTIAL") is True
|
||||
assert test_file.read_text().startswith("# © 2014-2026 Ultralytics Inc. CONFIDENTIAL\n\n")
|
||||
assert "Copyright" not in test_file.read_text()
|
||||
|
||||
|
||||
def test_update_file_no_changes():
|
||||
"""Test updating file with no changes needed."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
# Create a test file with correct header
|
||||
test_file = Path(tmp_dir) / "test.py"
|
||||
test_file.write_text("# Ultralytics 🚀 Test Header\n\nprint('Hello World')\n")
|
||||
|
||||
# Update file
|
||||
result = update_file(test_file, "# ", None, None, "Ultralytics 🚀 Test Header")
|
||||
|
||||
# Check results
|
||||
assert result is False # No changes made
|
||||
|
||||
|
||||
def test_update_file_edge_cases():
|
||||
"""Test header handling for empty, unreadable, and block-comment files."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
empty_file = tmp_path / "empty.py"
|
||||
empty_file.write_text("")
|
||||
assert update_file(empty_file, "# ", None, None, "Ultralytics 🚀 Test Header") is False
|
||||
assert update_file(tmp_path, "# ", None, None, "Ultralytics 🚀 Test Header") is False
|
||||
|
||||
css_file = tmp_path / "style.css"
|
||||
css_file.write_text("body { color: black; }\n")
|
||||
assert update_file(css_file, None, "/* ", " */", "Ultralytics 🚀 Test Header") is True
|
||||
assert css_file.read_text().startswith("/* Ultralytics 🚀 Test Header */\n\n")
|
||||
|
||||
|
||||
def test_comment_map_coverage():
|
||||
"""Test that all supported file extensions have defined comment styles."""
|
||||
# Check for a few key extensions
|
||||
assert ".py" in COMMENT_MAP
|
||||
assert ".cpp" in COMMENT_MAP
|
||||
assert ".js" in COMMENT_MAP
|
||||
assert ".html" in COMMENT_MAP
|
||||
|
||||
# Check comment style format
|
||||
for ext, (prefix, block_start, block_end) in COMMENT_MAP.items():
|
||||
assert isinstance(ext, str)
|
||||
assert prefix is None or isinstance(prefix, str)
|
||||
assert block_start is None or isinstance(block_start, str)
|
||||
assert block_end is None or isinstance(block_end, str)
|
||||
|
||||
|
||||
def test_ignore_paths():
|
||||
"""Test that the ignore paths list exists and contains expected entries."""
|
||||
assert isinstance(IGNORE_PATHS, set)
|
||||
assert ".git" in IGNORE_PATHS
|
||||
assert "__pycache__" in IGNORE_PATHS
|
||||
|
||||
|
||||
def test_main_real_files():
|
||||
"""Test main function on actual repository files."""
|
||||
with patch("actions.update_file_headers.update_file", return_value=False) as mock_update, patch(
|
||||
"actions.update_file_headers.Action"
|
||||
) as mock_action:
|
||||
mock_action.return_value.repository = "ultralytics/actions"
|
||||
mock_action.return_value.is_repo_private.return_value = False
|
||||
main()
|
||||
assert mock_update.call_count > 0
|
||||
|
||||
|
||||
def test_main_with_custom_header():
|
||||
"""Test main function with custom header environment variable."""
|
||||
with patch("actions.update_file_headers.update_file", return_value=False), patch(
|
||||
"actions.update_file_headers.HEADER", "Custom Test Header"
|
||||
), patch("actions.update_file_headers.Action") as mock_action:
|
||||
mock_event = mock_action.return_value
|
||||
mock_event.repository = "test/repo"
|
||||
main()
|
||||
mock_action.assert_called_once()
|
||||
|
||||
|
||||
def test_main_private_and_skipped_repos():
|
||||
"""Test main selects private headers and skips repos without a header source."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
(tmp_path / "app.py").write_text("print('hello')\n")
|
||||
|
||||
with patch("actions.update_file_headers.Path.cwd", return_value=tmp_path), patch(
|
||||
"actions.update_file_headers.update_file", return_value=False
|
||||
) as mock_update, patch("actions.update_file_headers.Action") as mock_action:
|
||||
mock_event = mock_action.return_value
|
||||
mock_event.repository = "ultralytics/private"
|
||||
mock_event.is_repo_private.return_value = True
|
||||
main()
|
||||
assert any("CONFIDENTIAL" in call.args[4] for call in mock_update.call_args_list)
|
||||
|
||||
with patch("actions.update_file_headers.update_file", return_value=False) as mock_update, patch(
|
||||
"actions.update_file_headers.HEADER", None
|
||||
), patch("actions.update_file_headers.Action") as mock_action:
|
||||
mock_action.return_value.repository = "other/repo"
|
||||
main()
|
||||
mock_action.return_value.is_repo_private.assert_not_called()
|
||||
mock_update.assert_not_called()
|
||||
|
||||
|
||||
def test_main_updates_files_in_current_directory():
|
||||
"""Test main updates supported files under the current directory."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
test_file = tmp_path / "app.py"
|
||||
test_file.write_text("print('hello')\n")
|
||||
|
||||
with patch("actions.update_file_headers.Path.cwd", return_value=tmp_path), patch(
|
||||
"actions.update_file_headers.Action"
|
||||
) as mock_action:
|
||||
mock_event = mock_action.return_value
|
||||
mock_event.repository = "ultralytics/actions"
|
||||
mock_event.is_repo_private.return_value = False
|
||||
main()
|
||||
|
||||
assert test_file.read_text().startswith("# Ultralytics 🚀 AGPL-3.0 License")
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import mock_open, patch
|
||||
from actions.update_markdown_code_blocks import (
|
||||
add_indentation,
|
||||
extract_code_blocks,
|
||||
format_bash_with_prettier,
|
||||
format_bash_with_shfmt,
|
||||
generate_temp_filename,
|
||||
main,
|
||||
process_markdown_file,
|
||||
@@ -110,11 +110,11 @@ def test():
|
||||
|
||||
|
||||
def test_format_bash_skips_when_no_shell_files(tmp_path):
|
||||
"""Test bash formatter skips Prettier when no shell snippets were extracted."""
|
||||
"""Test bash formatter skips shfmt when no shell snippets were extracted."""
|
||||
(tmp_path / "snippet.py").write_text("print('ok')", encoding="utf-8")
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
format_bash_with_prettier(tmp_path)
|
||||
format_bash_with_shfmt(tmp_path)
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@@ -134,6 +134,28 @@ def test_urls_with_different_tlds(verbose):
|
||||
assert mock_is_url.call_count == 5
|
||||
|
||||
|
||||
def test_replace_keeps_unresolved_links(monkeypatch):
|
||||
"""Replace links the search can fix, keep and report the ones it cannot, and never touch a longer neighbor."""
|
||||
text = "[Broken](https://site.test/bad) and [Fixed](https://site.test/gone) and https://site.test/gone/deeper"
|
||||
|
||||
def fake_is_url(url, session=None, check=True, max_attempts=3, timeout=3, return_url=False, redirect=False):
|
||||
valid = url in {"https://new.test", "https://site.test/gone/deeper"}
|
||||
return (valid, url) if return_url else valid
|
||||
|
||||
monkeypatch.setenv("BRAVE_API_KEY", "test-key")
|
||||
with patch("actions.utils.common_utils.is_url", side_effect=fake_is_url), patch(
|
||||
"actions.utils.common_utils.brave_search",
|
||||
side_effect=lambda query, *_, **__: [] if "Broken" in query else ["https://new.test"],
|
||||
):
|
||||
result = check_links_in_string(text, verbose=False, return_bad=True, replace=True)
|
||||
|
||||
assert result == (
|
||||
False,
|
||||
["https://site.test/bad"],
|
||||
"[Broken](https://site.test/bad) and [Fixed](https://new.test) and https://site.test/gone/deeper",
|
||||
)
|
||||
|
||||
|
||||
def test_case_sensitivity(verbose):
|
||||
"""Tests URL case sensitivity by verifying that URLs with different cases are correctly identified and handled."""
|
||||
text = "Case test: HTTPS://err.com and https://err.com"
|
||||
|
||||
Reference in New Issue
Block a user