feat(distribution): plugin ships the rules as a skill; skill file ref… #671
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: Every gallery still is gated by gallery_framing | |
| run: python3 tests/check_render_gates.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: Stage the public site exactly as pages.yml does | |
| run: python scripts/site/stage_public.py --src docs --out _site | |
| - name: Check every internal link, anchor and image alt (on the staged tree) | |
| run: python tests/check_site_links.py --root _site | |
| 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 plus every extra | |
| # directory in plugin.json "skills" (the generated blender-rules | |
| # skill); each SKILL.md needs name + description. | |
| extra = plugin.get('skills', []) | |
| skill_dirs = ['skills'] | |
| for d in extra: | |
| if not (isinstance(d, str) and d.startswith('./') and os.path.isdir(d)): | |
| errors.append(f'plugin.json skills entry {d!r} must be an existing ./ directory') | |
| else: | |
| skill_dirs.append(d) | |
| if './claude/skills/' not in extra: | |
| errors.append('plugin.json skills must include ./claude/skills/ (the rules skill)') | |
| for path in sorted(p for d in skill_dirs for p in glob.glob(os.path.join(d, '*', '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 and the rules skill are regenerated from rules/*.mdc | |
| run: python3 scripts/build_claude_rules.py --check | |
| - name: Check skill file references resolve outside a checkout | |
| run: python3 tests/check_skill_refs.py | |
| validate-counts: | |
| name: Validate content counts | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Check content counts match the repo | |
| 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')) | |
| ]) | |
| # Every stated copy of every count, across README, CLAUDE.md, AGENTS.md, | |
| # CONTRIBUTING.md and docs/new-example-prompt.md, plus the snippet cap. | |
| import subprocess | |
| if subprocess.run([sys.executable, 'tests/check_counts.py']).returncode: | |
| errors.append('tests/check_counts.py found stale counts (see above)') | |
| # The GitHub About text drifts silently otherwise. Warn only: it is | |
| # repo metadata edited by hand and must not block content PRs. | |
| 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} templates, {snippet_count} snippets, {example_count} examples, {showcase_count} showcase pieces') | |
| 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: Release gate fails closed (stubbed gh) | |
| run: python3 tests/test_release_gate.py -v | |
| - name: Release bump detection (bump-kind.sh) | |
| run: python3 tests/test_bump_kind.py -v | |
| - name: Release notes generator | |
| run: python3 tests/test_release_notes.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 |