name: Validate SKILL.md files on: push: paths: - 'skills/**' - 'tools/**' - '.github/workflows/validate-skills.yml' pull_request: paths: - 'skills/**' - 'tools/**' - '.github/workflows/validate-skills.yml' workflow_dispatch: jobs: validate: runs-on: ubuntu-latest name: Validate SKILL.md frontmatter steps: - uses: actions/checkout@v4 - name: Install dependencies run: pip install pyyaml # All frontmatter is parsed by tools/skill_frontmatter.py (PyYAML). Any # reintroduced regex parser silently truncates multi-line descriptions -- # that bug shipped 604/817 broken descriptions before it was caught. - name: Guard against hand-rolled YAML parsers run: | if grep -rnE '(re\.(search|match|compile)\([^)]*description|^\s*description:.*\(\.\*\))' \ tools/ --include='*.py' ; then echo "::error::Regex-based frontmatter parsing detected. Use tools/skill_frontmatter.py." exit 1 fi echo "OK: no regex frontmatter parsers" # Single source of truth: tools/validate-skill.py validates required # frontmatter fields, kebab-case name, description length, subdomain, and # tag count. (Previously this step duplicated a weaker inline parser.) - name: Validate SKILL.md frontmatter run: python3 tools/validate-skill.py --all # agentskills.io conformance: name==directory, 1..1024 description, # reserved-word ban, angle-bracket injection check. - name: Validate agentskills.io conformance run: python3 tools/validate-agentskills.py --strict # index.json is generated; a PR that changes a description must regenerate it. # Pull requests only: on a push to main, update-index.yml regenerates # index.json in parallel with this job, so checking here would race and go # red on every merge that adds a skill before self-healing seconds later. - name: Check index.json is current if: github.event_name == 'pull_request' run: python3 tools/generate-index.py --check # Description quality gate. Pre-existing failures are grandfathered in # tools/lint-baseline.json so this blocks NEW debt only; the baseline is # allowed to shrink and never to grow. - name: Lint descriptions run: python3 tools/lint-descriptions.py --all --stats # Ratchet: the number of unreviewed near-duplicate description pairs may # never increase. Lower this cap as disambiguation lands. - name: Detect skill collisions run: python3 tools/detect-collisions.py --max-unreviewed 55 - name: Check for duplicate skill names run: | python3 << 'EOF' import os import re from collections import Counter names = [] for root, dirs, files in os.walk('skills'): for file in files: if file == 'SKILL.md': path = os.path.join(root, file) with open(path, 'r', encoding='utf-8') as f: content = f.read() fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if fm_match: name_match = re.search(r'^name:\s*(.+)$', fm_match.group(1), re.MULTILINE) if name_match: names.append(name_match.group(1).strip().strip('"')) duplicates = [name for name, count in Counter(names).items() if count > 1] if duplicates: print(f"❌ Duplicate skill names found: {duplicates}") exit(1) print(f"✅ No duplicate names in {len(names)} skills") EOF - name: Report skill counts if: always() run: | echo "## Skill Database Stats" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY python3 << 'EOF' import os import re from collections import Counter subdomain_counts = Counter() total = 0 for root, dirs, files in os.walk('skills'): for file in files: if file == 'SKILL.md': total += 1 path = os.path.join(root, file) with open(path, 'r', encoding='utf-8') as f: content = f.read() fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if fm_match: sd_match = re.search(r'^subdomain:\s*(.+)$', fm_match.group(1), re.MULTILINE) if sd_match: subdomain_counts[sd_match.group(1).strip()] += 1 print(f"**Total Skills: {total}**") print("") print("| Subdomain | Count |") print("|-----------|-------|") for sd, count in sorted(subdomain_counts.items(), key=lambda x: -x[1]): print(f"| {sd} | {count} |") EOF