docs(license): MIT for snippets/ and templates/ #633
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate: | |
| name: Validate structure and frontmatter | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Check referenced paths exist | |
| run: | | |
| echo "Checking referenced paths..." | |
| test -d skills || { echo "ERROR: skills/ directory missing"; exit 1; } | |
| test -d rules || { echo "ERROR: rules/ directory missing"; exit 1; } | |
| test -d templates || { echo "ERROR: templates/ directory missing"; exit 1; } | |
| test -d snippets || { echo "ERROR: snippets/ directory missing"; exit 1; } | |
| test -f VERSION || { echo "ERROR: VERSION file missing"; exit 1; } | |
| test -f LICENSE || { echo "ERROR: LICENSE file missing"; exit 1; } | |
| echo "All referenced paths exist." | |
| - name: Validate VERSION file | |
| run: | | |
| version=$(cat VERSION | tr -d '[:space:]') | |
| if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then | |
| echo "ERROR: VERSION '$version' is not valid semver" | |
| exit 1 | |
| fi | |
| echo "VERSION valid: $version" | |
| - name: Validate skill and rule frontmatter (parsed YAML, first block only) | |
| run: | | |
| pip install PyYAML==6.0.2 | |
| python3 tests/check_frontmatter.py | |
| - name: Validate snippet Python syntax | |
| run: | | |
| echo "Checking snippet Python syntax..." | |
| errors=0 | |
| for snippet in snippets/*.py; do | |
| if [ ! -f "$snippet" ]; then continue; fi | |
| if ! python3 -m py_compile "$snippet" 2>/dev/null; then | |
| echo "ERROR: $snippet has invalid Python syntax" | |
| errors=$((errors + 1)) | |
| fi | |
| done | |
| if [ "$errors" -gt 0 ]; then | |
| echo "$errors snippet syntax error(s) found." | |
| exit 1 | |
| fi | |
| echo "All snippets have valid Python syntax." | |
| - name: Check import-scale and unevaluated-export anti-patterns | |
| run: python3 tests/check_import_export_rules.py | |
| - name: Check the smoke catalog covers every example and showcase piece | |
| run: python3 tests/check_smoke_catalog.py | |
| - name: Check product exit codes are in README tables | |
| run: python3 tests/check_exit_code_readme.py | |
| - name: Check EEVEE engine-id mappings | |
| run: python3 tests/check_engine_id.py | |
| - name: Check falsifiers declare the budget they target | |
| run: python3 tests/check_falsifier_targets.py | |
| - name: Check committed gallery matches its generator | |
| # docs/gallery/ is committed, but pages.yml regenerates it at deploy, so a | |
| # stale commit ships fine and drifts silently. Regenerate and require no diff. | |
| run: | | |
| python3 scripts/build_gallery.py | |
| drift=$(git status --porcelain -- docs/gallery) | |
| if [ -n "$drift" ]; then | |
| echo "::error::docs/gallery/ is stale. Run 'python scripts/build_gallery.py' and commit the result." | |
| echo "$drift" | |
| git --no-pager diff --stat -- docs/gallery | |
| exit 1 | |
| fi | |
| echo "docs/gallery/ matches scripts/build_gallery.py output." | |
| - name: Check gallery hero and preview sizes | |
| run: python3 tests/check_gallery_images.py | |
| - name: Validate template Python syntax | |
| run: | | |
| echo "Checking template Python syntax..." | |
| errors=0 | |
| while IFS= read -r -d '' py; do | |
| if ! python3 -m py_compile "$py" 2>/dev/null; then | |
| echo "ERROR: $py has invalid Python syntax" | |
| errors=$((errors + 1)) | |
| fi | |
| done < <(find templates -name '*.py' -print0) | |
| if [ "$errors" -gt 0 ]; then | |
| echo "$errors template syntax error(s) found." | |
| exit 1 | |
| fi | |
| echo "All template Python files have valid syntax." | |
| - name: Count components | |
| run: | | |
| skill_count=$(ls -d skills/*/SKILL.md 2>/dev/null | wc -l) | |
| rule_count=$(ls rules/*.mdc 2>/dev/null | wc -l) | |
| template_count=$(find templates -mindepth 1 -maxdepth 1 -type d | wc -l) | |
| snippet_count=$(ls snippets/*.py 2>/dev/null | wc -l) | |
| echo "Skills: $skill_count" | |
| echo "Rules: $rule_count" | |
| echo "Templates: $template_count" | |
| echo "Snippets: $snippet_count" | |
| validate-site: | |
| name: Build landing page and check site links | |
| # The landing page is built only at deploy (docs/index.html is untracked), | |
| # so a template error or a dead link otherwise surfaces in production. | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| cache-dependency-path: scripts/site/requirements.txt | |
| - run: pip install -r scripts/site/requirements.txt | |
| - name: Build landing page into docs/ | |
| run: python scripts/site/build_site.py --repo-root . --out docs | |
| - name: Check every internal link, anchor and image alt | |
| run: python tests/check_site_links.py | |
| validate-manifest: | |
| name: Validate plugin manifest | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Check plugin.json matches filesystem and VERSION | |
| run: | | |
| python3 << 'PYEOF' | |
| import glob | |
| import json | |
| import os | |
| import sys | |
| errors = [] | |
| manifest = json.load(open('.cursor-plugin/plugin.json')) | |
| version = open('VERSION').read().strip() | |
| if manifest.get('version') != version: | |
| errors.append( | |
| f"plugin.json version '{manifest.get('version')}' != VERSION '{version}'" | |
| ) | |
| # Every manifest path must exist on disk. | |
| for key in ('skills', 'rules', 'snippets', 'templates', 'examples', 'showcase'): | |
| for path in manifest.get(key, []): | |
| if not os.path.exists(path): | |
| errors.append(f'{key}: manifest lists missing path {path}') | |
| # Every content file/dir on disk must be listed in the manifest. | |
| expected = { | |
| 'skills': sorted(glob.glob('skills/*/SKILL.md')), | |
| 'rules': sorted(glob.glob('rules/*.mdc')), | |
| 'snippets': sorted(glob.glob('snippets/*.py')), | |
| 'templates': sorted( | |
| d for d in glob.glob('templates/*') if os.path.isdir(d) | |
| ), | |
| 'examples': sorted( | |
| d for d in glob.glob('examples/*') if os.path.isdir(d) | |
| ), | |
| 'showcase': sorted( | |
| d for d in glob.glob('showcase/*') if os.path.isdir(d) | |
| ), | |
| } | |
| for key, paths in expected.items(): | |
| listed = {p.replace('\\', '/') for p in manifest.get(key, [])} | |
| for path in paths: | |
| if path.replace('\\', '/') not in listed: | |
| errors.append(f'{key}: {path} on disk but not in manifest') | |
| # Every showcase piece names a category the gallery build knows. | |
| sys.path.insert(0, 'scripts') | |
| from build_gallery import CATEGORIES | |
| for piece in json.load(open('showcase/gallery.json', encoding='utf-8'))['pieces']: | |
| if piece.get('category') not in CATEGORIES: | |
| errors.append( | |
| f"showcase/gallery.json: {piece['name']} category " | |
| f"{piece.get('category')!r} not in {sorted(CATEGORIES)}" | |
| ) | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| counts = {k: len(manifest.get(k, [])) for k in expected} | |
| print(f'Manifest verified at v{version}: {counts}') | |
| PYEOF | |
| validate-claude-packaging: | |
| name: Validate Claude Code packaging | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Check .claude-plugin manifests match VERSION and the skills on disk | |
| run: | | |
| python3 << 'PYEOF' | |
| import glob | |
| import json | |
| import os | |
| import sys | |
| errors = [] | |
| version = open('VERSION').read().strip() | |
| plugin = json.load(open('.claude-plugin/plugin.json', encoding='utf-8')) | |
| market = json.load(open('.claude-plugin/marketplace.json', encoding='utf-8')) | |
| if plugin.get('version') != version: | |
| errors.append(f"claude plugin.json version {plugin.get('version')!r} != VERSION {version!r}") | |
| if plugin.get('name') != 'blender-developer-tools': | |
| errors.append('claude plugin.json name must be blender-developer-tools') | |
| entries = market.get('plugins', []) | |
| if len(entries) != 1 or entries[0].get('name') != plugin.get('name'): | |
| errors.append('marketplace.json must list exactly the one plugin from plugin.json') | |
| elif entries[0].get('version') != version: | |
| errors.append(f"marketplace.json plugin version {entries[0].get('version')!r} != VERSION {version!r}") | |
| elif not os.path.isdir(os.path.join(entries[0].get('source', ''), 'skills')): | |
| errors.append('marketplace.json source must point at a directory containing skills/') | |
| # Claude Code discovers skills/<name>/SKILL.md; each needs name + description. | |
| for path in sorted(glob.glob('skills/*/SKILL.md')): | |
| parts = open(path, encoding='utf-8').read().replace('\r\n', '\n').split('---') | |
| front = parts[1] if len(parts) > 2 else '' | |
| for key in ('name:', 'description:'): | |
| if key not in front: | |
| errors.append(f'{path}: frontmatter missing {key}') | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| print('Claude Code packaging verified') | |
| PYEOF | |
| - name: Check every workflow action is pinned to a full commit SHA | |
| run: | | |
| bad=$(grep -rnE '^\s*(-\s+)?uses:\s+[^./ ]' .github/workflows \ | |
| | grep -vE 'uses:\s+\S+@[0-9a-f]{40}(\s|$)' || true) | |
| if [ -n "$bad" ]; then | |
| echo "$bad" | |
| echo "::error::pin these actions to a full commit SHA with a trailing '# vX.Y' comment (#306)" | |
| exit 1 | |
| fi | |
| - name: Check CLAUDE.md carries no personal plugin routing block | |
| run: | | |
| if grep -nE 'context-mode|MANDATORY routing rules|ctx_(execute|batch_execute|search|fetch_and_index)' CLAUDE.md; then | |
| echo "::error::CLAUDE.md contains a personal plugin routing block (see #288)" | |
| exit 1 | |
| fi | |
| - name: Check claude/blender-rules.md is regenerated from rules/*.mdc | |
| run: python3 scripts/build_claude_rules.py --check | |
| validate-counts: | |
| name: Validate content counts | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Check content counts match README | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| python3 << 'PYEOF' | |
| import os | |
| import sys | |
| errors = [] | |
| skill_count = len([ | |
| d for d in os.listdir('skills') | |
| if os.path.isdir(os.path.join('skills', d)) | |
| and os.path.exists(os.path.join('skills', d, 'SKILL.md')) | |
| ]) | |
| rule_count = len([ | |
| f for f in os.listdir('rules') | |
| if f.endswith('.mdc') | |
| ]) | |
| template_count = len([ | |
| d for d in os.listdir('templates') | |
| if os.path.isdir(os.path.join('templates', d)) | |
| ]) | |
| snippet_count = len([ | |
| f for f in os.listdir('snippets') | |
| if f.endswith('.py') | |
| ]) | |
| example_count = len([ | |
| d for d in os.listdir('examples') | |
| if os.path.isdir(os.path.join('examples', d)) | |
| and os.path.exists(os.path.join('examples', d, 'README.md')) | |
| ]) | |
| showcase_count = 0 | |
| if os.path.isdir('showcase'): | |
| showcase_count = len([ | |
| d for d in os.listdir('showcase') | |
| if os.path.isdir(os.path.join('showcase', d)) | |
| and os.path.exists(os.path.join('showcase', d, 'README.md')) | |
| ]) | |
| readme = open('README.md').read() | |
| if f'{skill_count} skills' not in readme: | |
| errors.append(f'README skill count mismatch (expected "{skill_count} skills" substring)') | |
| if f'{rule_count} rules' not in readme: | |
| errors.append(f'README rule count mismatch (expected "{rule_count} rules" substring)') | |
| template_word = 'template' if template_count == 1 else 'templates' | |
| if f'{template_count} {template_word}' not in readme: | |
| errors.append(f'README template count mismatch (expected "{template_count} {template_word}" substring)') | |
| if f'{snippet_count} snippets' not in readme: | |
| errors.append(f'README snippet count mismatch (expected "{snippet_count} snippets" substring)') | |
| if f'{example_count} examples' not in readme: | |
| errors.append(f'README example count mismatch (expected "{example_count} examples" substring)') | |
| showcase_word = 'piece' if showcase_count == 1 else 'pieces' | |
| showcase_needle = f'{showcase_count} showcase {showcase_word}' | |
| if showcase_needle not in readme: | |
| errors.append( | |
| f'README showcase count mismatch (expected "{showcase_needle}" substring)' | |
| ) | |
| # Documented snippet length cap (README, CLAUDE.md, CONTRIBUTING.md). | |
| for f in sorted(os.listdir('snippets')): | |
| if f.endswith('.py'): | |
| n = len(open(os.path.join('snippets', f), encoding='utf-8').read().splitlines()) | |
| if not 5 <= n <= 75: | |
| errors.append(f'snippets/{f} is {n} lines (documented range is 5 to 75)') | |
| # Secondary docs must not carry stale aggregate counts. | |
| import re | |
| for doc in ('AGENTS.md', 'CONTRIBUTING.md'): | |
| text = open(doc, encoding='utf-8').read() | |
| for kind, actual in (('examples', example_count), ('showcase pieces', showcase_count)): | |
| for m in re.finditer(r'(\d+) ' + kind, text): | |
| if int(m.group(1)) != actual: | |
| errors.append(f'{doc} says "{m.group(0)}" but the repo has {actual}') | |
| # The GitHub About text drifts silently otherwise. Warn only: it is | |
| # repo metadata edited by hand and must not block content PRs. | |
| import subprocess | |
| try: | |
| about = subprocess.run( | |
| ['gh', 'repo', 'view', '--json', 'description', '--jq', '.description'], | |
| capture_output=True, text=True, timeout=60, check=True, | |
| ).stdout | |
| for needle in (f'{example_count} examples', f'{showcase_count} showcase'): | |
| if needle not in about: | |
| print(f'::warning::GitHub repo description is stale (missing "{needle}"): {about.strip()}') | |
| except (subprocess.SubprocessError, OSError) as exc: | |
| print(f'::warning::could not read the GitHub repo description: {exc}') | |
| if errors: | |
| for e in errors: | |
| print(f'::error::{e}', file=sys.stderr) | |
| sys.exit(1) | |
| print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} {template_word}, {snippet_count} snippets, {example_count} examples, {showcase_needle}') | |
| PYEOF | |
| validate-harness: | |
| name: Validate smoke harness protocol | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Harness unit tests | |
| run: python3 tests/smoke/test_harness.py -v | |
| - name: Hero-drift tool exit-code tests | |
| run: | | |
| pip install numpy==2.2.6 Pillow==11.2.1 | |
| python3 tests/test_measure_hero_drift.py -v |