Add CI validation workflow for tool submissions

- Validates config.yaml schema and required fields
- Checks for valid semver versions
- Validates category and tags
- Scans for potential secrets in prompts
- Warns if README.md is missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-31 22:50:20 -04:00
parent d94a497cda
commit 96393e56d5
1 changed files with 168 additions and 0 deletions

View File

@ -0,0 +1,168 @@
name: Validate Tool Submission
on:
pull_request:
paths:
- 'tools/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install pyyaml
- name: Get changed files
id: changed
run: |
echo "files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^tools/' | tr '\n' ' ')" >> $GITHUB_OUTPUT
- name: Validate tool configs
run: |
python3 << 'EOF'
import sys
import yaml
import re
from pathlib import Path
SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+.+)?$')
TOOL_NAME_RE = re.compile(r'^[A-Za-z0-9-]{1,64}$')
OWNER_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$|^[a-z0-9]$')
VALID_CATEGORIES = {
'text-processing', 'code', 'data', 'media', 'productivity',
'writing', 'translation', 'research', 'summarization'
}
errors = []
warnings = []
# Find all config.yaml files in tools/
for config_path in Path('tools').glob('*/*/config.yaml'):
owner = config_path.parent.parent.name
tool_name = config_path.parent.name
print(f"Validating {owner}/{tool_name}...")
# Validate owner slug
if not OWNER_RE.match(owner):
errors.append(f"{config_path}: Invalid owner slug '{owner}'")
continue
# Validate tool name
if not TOOL_NAME_RE.match(tool_name):
errors.append(f"{config_path}: Invalid tool name '{tool_name}'")
continue
# Parse YAML
try:
with open(config_path) as f:
config = yaml.safe_load(f)
except yaml.YAMLError as e:
errors.append(f"{config_path}: Invalid YAML - {e}")
continue
if not config:
errors.append(f"{config_path}: Empty config file")
continue
# Required fields
if not config.get('name'):
errors.append(f"{config_path}: Missing 'name' field")
elif config['name'] != tool_name:
errors.append(f"{config_path}: name '{config['name']}' doesn't match directory '{tool_name}'")
if not config.get('version'):
errors.append(f"{config_path}: Missing 'version' field")
elif not SEMVER_RE.match(str(config['version'])):
errors.append(f"{config_path}: Invalid version '{config['version']}' (must be semver)")
if not config.get('description'):
warnings.append(f"{config_path}: Missing 'description' field")
elif len(config['description']) > 500:
errors.append(f"{config_path}: Description too long (max 500 chars)")
# Category validation
category = config.get('category', '')
if category and category not in VALID_CATEGORIES:
warnings.append(f"{config_path}: Unknown category '{category}'")
# Tags validation
tags = config.get('tags', [])
if not isinstance(tags, list):
errors.append(f"{config_path}: 'tags' must be a list")
elif len(tags) > 10:
errors.append(f"{config_path}: Too many tags (max 10)")
# Steps validation
steps = config.get('steps', [])
if not steps:
errors.append(f"{config_path}: Missing 'steps' field")
else:
for i, step in enumerate(steps):
if step.get('type') == 'prompt':
if not step.get('prompt'):
errors.append(f"{config_path}: Step {i} missing 'prompt' field")
if not step.get('output_var'):
errors.append(f"{config_path}: Step {i} missing 'output_var' field")
# Check README exists
readme_path = config_path.parent / 'README.md'
if not readme_path.exists():
warnings.append(f"{config_path}: No README.md found")
print(f" ✓ {owner}/{tool_name} validated")
# Print results
if warnings:
print("\n⚠ Warnings:")
for w in warnings:
print(f" - {w}")
if errors:
print("\n❌ Errors:")
for e in errors:
print(f" - {e}")
sys.exit(1)
print("\n✅ All tools validated successfully!")
EOF
- name: Check for secrets in prompts
run: |
python3 << 'EOF'
import re
from pathlib import Path
SECRET_PATTERNS = [
(r'(?i)(api[_-]?key|apikey)\s*[:=]\s*["\'][^"\']+["\']', 'API key'),
(r'(?i)(secret|password|token)\s*[:=]\s*["\'][^"\']+["\']', 'secret/password'),
(r'sk-[a-zA-Z0-9]{20,}', 'OpenAI API key'),
(r'ghp_[a-zA-Z0-9]{36}', 'GitHub token'),
]
issues = []
for config_path in Path('tools').glob('*/*/config.yaml'):
content = config_path.read_text()
for pattern, name in SECRET_PATTERNS:
if re.search(pattern, content):
issues.append(f"{config_path}: Possible {name} found in config")
if issues:
print("❌ Security issues found:")
for issue in issues:
print(f" - {issue}")
exit(1)
print("✅ No secrets detected in configs")
EOF