From 96393e56d5d7a1f47a2c1068121629816a8bfe4c Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 31 Dec 2025 22:50:20 -0400 Subject: [PATCH] Add CI validation workflow for tool submissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitea/workflows/validate.yaml | 168 +++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 .gitea/workflows/validate.yaml diff --git a/.gitea/workflows/validate.yaml b/.gitea/workflows/validate.yaml new file mode 100644 index 0000000..dc2d2d2 --- /dev/null +++ b/.gitea/workflows/validate.yaml @@ -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