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:
parent
d94a497cda
commit
96393e56d5
|
|
@ -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
|
||||||
Loading…
Reference in New Issue