Skip to content

feat(distribution): plugin ships the rules as a skill; skill file ref… #671

feat(distribution): plugin ships the rules as a skill; skill file ref…

feat(distribution): plugin ships the rules as a skill; skill file ref… #671

Workflow file for this run

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